From: jenkins-bot Date: Thu, 12 Apr 2018 19:54:51 +0000 (+0000) Subject: Merge "Test ApiUserrights" X-Git-Tag: 1.31.0-rc.0~96 X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=commitdiff_plain;h=54d50ef3921dc9f30dc4d863ddc471dc564998e9;hp=5ab1bee6bcf83bd133b48c79fd72da92f58037d4 Merge "Test ApiUserrights" --- diff --git a/.mailmap b/.mailmap index 08e1aaa15f..77d2b8dd3d 100644 --- a/.mailmap +++ b/.mailmap @@ -107,7 +107,8 @@ Christoph Jauera Christoph Jauera Christopher Johnson church of emacs -Cindy Cicalese +Cindy Cicalese +Cindy Cicalese ckoerner Conrad Irwin Dan Duvall diff --git a/RELEASE-NOTES-1.31 b/RELEASE-NOTES-1.31 index ebd978721b..ea3aa8bd3c 100644 --- a/RELEASE-NOTES-1.31 +++ b/RELEASE-NOTES-1.31 @@ -32,6 +32,7 @@ production. performance reasons, and installations with this setting will now work as if it was configured with 'any'. * $wgLogAutopatrol now defaults to false instead of true. +* $wgValidateAllHtml was removed and will be ignored. === New features in 1.31 === * (T76554) User sub-pages named ….json are now protected in the same way that ….js @@ -239,6 +240,11 @@ changes to languages because of Phabricator reports. * The ResourceLoaderGetLessVars hook, deprecated in 1.30, has been removed. Use ResourceLoaderModule::getLessVars() to expose local variables instead of global ones. +* As part of work to modernise user-generated content clean-up, a config option and some + methods related to HTML validity were removed without deprecation. The public methods + MWTidy::checkErrors() and its callee TidyDriverBase::validate() are removed, as are + MediaWikiTestCase::assertValidHtmlSnippet() and ::assertValidHtmlDocument(). The + $wgValidateAllHtml configuration option is removed and will be ignored. === Deprecations in 1.31 === * The Revision class was deprecated in favor of RevisionStore, BlobStore, and @@ -325,6 +331,8 @@ changes to languages because of Phabricator reports. * The type string for the parameter $lang of DateFormatter::getInstance is deprecated. * Wikimedia\Rdbms\SavepointPostgres is deprecated. +* The DO_MAINTENANCE constant is deprecated. RUN_MAINTENANCE_IF_MAIN should be + used instead. === Other changes in 1.31 === * Browser support for Internet Explorer 10 was lowered from Grade A to Grade C. diff --git a/autoload.php b/autoload.php index 4076620f30..fc610cfd18 100644 --- a/autoload.php +++ b/autoload.php @@ -1675,6 +1675,7 @@ $wgAutoloadLocalClasses = [ 'WikiTextStructure' => __DIR__ . '/includes/content/WikiTextStructure.php', 'Wikimedia\\Http\\HttpAcceptNegotiator' => __DIR__ . '/includes/libs/http/HttpAcceptNegotiator.php', 'Wikimedia\\Http\\HttpAcceptParser' => __DIR__ . '/includes/libs/http/HttpAcceptParser.php', + 'Wikimedia\\Rdbms\\AtomicSectionIdentifier' => __DIR__ . '/includes/libs/rdbms/database/AtomicSectionIdentifier.php', 'Wikimedia\\Rdbms\\Blob' => __DIR__ . '/includes/libs/rdbms/encasing/Blob.php', 'Wikimedia\\Rdbms\\ChronologyProtector' => __DIR__ . '/includes/libs/rdbms/ChronologyProtector.php', 'Wikimedia\\Rdbms\\ConnectionManager' => __DIR__ . '/includes/libs/rdbms/connectionmanager/ConnectionManager.php', diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index c000098302..22f587ee15 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -3265,12 +3265,6 @@ $wgSiteNotice = ''; */ $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. diff --git a/includes/OutputHandler.php b/includes/OutputHandler.php index 842922d566..16c37841c8 100644 --- a/includes/OutputHandler.php +++ b/includes/OutputHandler.php @@ -22,9 +22,6 @@ namespace MediaWiki; -use MWTidy; -use Html; - /** * @since 1.31 */ @@ -36,31 +33,10 @@ class OutputHandler { * @return string */ public static function handle( $s ) { - global $wgDisableOutputCompression, $wgValidateAllHtml, $wgMangleFlashPolicy; + global $wgDisableOutputCompression, $wgMangleFlashPolicy; if ( $wgMangleFlashPolicy ) { $s = self::mangleFlashPolicy( $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 = self::validateAllHtml( $s ); - } - } if ( !$wgDisableOutputCompression && !ini_get( 'zlib.output_compression' ) ) { if ( !defined( 'MW_NO_OUTPUT_COMPRESSION' ) ) { $s = self::handleGzip( $s ); @@ -183,65 +159,4 @@ class OutputHandler { header( "Content-Length: $length" ); } } - - /** - * Replace the output with an error if the HTML is not valid. - * - * @param string $s - * @return string - */ - private static function validateAllHtml( $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 = << '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/includes/api/ApiParse.php b/includes/api/ApiParse.php index cbd62a97df..099d278f0c 100644 --- a/includes/api/ApiParse.php +++ b/includes/api/ApiParse.php @@ -243,12 +243,6 @@ class ApiParse extends ApiBase { 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'] ) ) { @@ -400,8 +394,8 @@ class ApiParse extends ApiBase { } if ( isset( $prop['displaytitle'] ) ) { - $result_array['displaytitle'] = $p_result->getDisplayTitle() ?: - $titleObj->getPrefixedText(); + $result_array['displaytitle'] = $p_result->getDisplayTitle() !== false + ? $p_result->getDisplayTitle() : $titleObj->getPrefixedText(); } if ( isset( $prop['headitems'] ) ) { @@ -490,12 +484,7 @@ class ApiParse extends ApiBase { } $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(); - } + $xml = $wgParser->preprocessToDom( $this->content->getNativeData() )->__toString(); $result_array['parsetree'] = $xml; $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'parsetree'; } @@ -578,7 +567,7 @@ class ApiParse extends ApiBase { } else { $this->content = $page->getContent( Revision::FOR_THIS_USER, $this->getUser() ); if ( !$this->content ) { - $this->dieWithError( [ 'apierror-missingcontent-pageid', $pageId ] ); + $this->dieWithError( [ 'apierror-missingcontent-pageid', $page->getId() ] ); } } $this->contentIsDeleted = $isDeleted; @@ -602,7 +591,7 @@ class ApiParse extends ApiBase { $pout = $page->getParserOutput( $popts, $revId, $suppressCache ); } if ( !$pout ) { - $this->dieWithError( [ 'apierror-nosuchrevid', $revId ?: $page->getLatest() ] ); + $this->dieWithError( [ 'apierror-nosuchrevid', $revId ?: $page->getLatest() ] ); // @codeCoverageIgnore } return $pout; diff --git a/includes/api/i18n/de.json b/includes/api/i18n/de.json index 997888d815..b1eee12d6d 100644 --- a/includes/api/i18n/de.json +++ b/includes/api/i18n/de.json @@ -72,6 +72,8 @@ "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-diff": "Das Unterschieds-HTML.", + "apihelp-compare-paramvalue-prop-diffsize": "Die Größe des Unterschieds-HTML in Bytes.", "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.", @@ -749,7 +751,10 @@ "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-paramvalue-prop-varianttitles": "Gibt den Anzeigetitel in allen Varianten der Sprache des Websiteinhalts aus.", "apihelp-query+info-param-testactions": "Überprüft, ob der aktuelle Benutzer gewisse Aktionen auf der Seite ausführen kann.", + "apihelp-query+info-example-simple": "Ruft Informationen über die Seite Hauptseite ab.", + "apihelp-query+iwbacklinks-summary": "Findet alle Seiten, die auf einen angegebenen Interwikilink verlinken.", "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:", @@ -764,6 +769,7 @@ "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-paramvalue-prop-lltitle": "Ergänzt den Titel des Sprachlinks.", "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.", @@ -780,6 +786,7 @@ "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-paramvalue-prop-redirect": "Markieren, falls die Seite eine Weiterleitung ist.", "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.", @@ -788,8 +795,10 @@ "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-timestamp": "Ergänzt den Zeitstempel des Logbucheintrags.", "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-type": "Filtert nur Logbucheinträge mit diesem Typ heraus.", "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.", @@ -803,6 +812,7 @@ "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-summary": "Listet alle Titel auf, die vor einer Erstellung geschützt sind.", "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.", diff --git a/includes/api/i18n/fr.json b/includes/api/i18n/fr.json index ea2d5a22b7..30b262e81d 100644 --- a/includes/api/i18n/fr.json +++ b/includes/api/i18n/fr.json @@ -856,6 +856,7 @@ "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-paramvalue-prop-varianttitles": "Donne le titre affiché dans toutes les variantes de la langue de contenu du site.", "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 Main Page.", diff --git a/includes/api/i18n/he.json b/includes/api/i18n/he.json index 4e019cbf63..c6810a9ed3 100644 --- a/includes/api/i18n/he.json +++ b/includes/api/i18n/he.json @@ -836,6 +836,7 @@ "apihelp-query+info-paramvalue-prop-readable": "האם המשתמש יכול להציג דף זה.", "apihelp-query+info-paramvalue-prop-preload": "נותן את הטקסט שמוחזר על־ידי EditFormPreloadText.", "apihelp-query+info-paramvalue-prop-displaytitle": "נותן את האופן שבה שם הדף באמת מוצג.", + "apihelp-query+info-paramvalue-prop-varianttitles": "כותרת התצוגה בכל הגרסאות של שפת התוכן של האתר.", "apihelp-query+info-param-testactions": "בדיקה האם המשתמש הנוכחי יכול לבצע פעולות מסוימות על הדף.", "apihelp-query+info-param-token": "להשתמש ב־[[Special:ApiHelp/query+tokens|action=query&meta=tokens]] במקום.", "apihelp-query+info-example-simple": "קבלת מידע על הדף Main Page", diff --git a/includes/api/i18n/ko.json b/includes/api/i18n/ko.json index c2ec96c688..354e75cf03 100644 --- a/includes/api/i18n/ko.json +++ b/includes/api/i18n/ko.json @@ -426,7 +426,7 @@ "apihelp-query+fileusage-param-limit": "반환할 항목 수.", "apihelp-query+fileusage-param-show": "이 기준을 충족하는 항목만 표시합니다:\n;redirect:넘겨주기만 표시합니다.\n;!redirect:넘겨주기가 아닌 항목만 표시합니다.", "apihelp-query+imageinfo-summary": "파일 정보와 업로드 역사를 반환합니다.", - "apihelp-query+imageinfo-param-prop": "가져올 파일 정보입니다:\n;timestamp:업로드한 판의 타임스탬프를 추가합니다.\n;user:각 파일 판을 업로드한 사용자를 추가합니다.\n;userid:각 파일 판을 업로드한 사용자 ID를 추가합니다.\n;comment:판의 설명입니다.\n;parsedcomment:판의 설명을 구문 분석합니다.\n;canonicaltitle:파일의 정식 제목을 추가합니다.\n;url:파일 및 설명 문서의 URL을 지정합니다.\n;size:바이트, 높이, 너비, 쪽수 단위로 파일의 크기를 추가합니다. (해당하는 경우)\n;dimensions:크기의 다른 이름입니다.\n;sha1:파일의 SHA-1 해시를 추가합니다.\n;mime:파일의 MIME 타입을 추가합니다.\n;thumbmime:이미지 섬네일의 MIME 타입을 추가합니다. (url과 $1urlwidth 변수 필요)\n;mediatype:파일의 미디어 유형을 추가합니다.\n;metadata:파일의 판의 Exif 메타데이터를 나열합니다.\n;commonmetadata:파일의 판의 파일 포맷 일반 메타데이터를 나열합니다.\n;extmetadata:여러 출처로부터 병합한 서식화된 메타데이터를 나열합니다. 결과는 HTML 포맷입니다.\n;archivename:최신 판이 아닌 아카이브 판의 파일 이름을 추가합니다.\n;bitdepth:판의 비트 깊이를 추가합니다.\n;uploadwarning:기존 파일에 관한 정보를 가져오기 위해 특수:올리기 문서에 사용됩니다. 미디어위키 코어 밖에서 사용할 목적으로 고안되지 않았습니다.", + "apihelp-query+imageinfo-param-prop": "가져올 파일 정보입니다:", "apihelp-query+imageinfo-paramvalue-prop-timestamp": "업로드된 판에 대한 타임스탬프를 추가합니다.", "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "판의 설명을 구문 분석합니다.", "apihelp-query+imageinfo-paramvalue-prop-sha1": "파일에 대한 SHA-1 해시를 추가합니다.", @@ -445,6 +445,7 @@ "apihelp-query+info-param-prop": "얻고자 하는 추가 속성:", "apihelp-query+info-paramvalue-prop-protection": "각 문서의 보호 수준을 나열합니다.", "apihelp-query+info-paramvalue-prop-readable": "사용자가 이 문서를 읽을 수 있는지의 여부.", + "apihelp-query+info-paramvalue-prop-varianttitles": "모든 종류의 사이트 내용 언어의 표시 제목을 지정합니다.", "apihelp-query+iwbacklinks-summary": "제시된 인터위키 링크에 연결된 모든 문서를 찾습니다.", "apihelp-query+iwbacklinks-param-prefix": "인터위키의 접두사.", "apihelp-query+iwbacklinks-param-title": "검색할 인터위키 링크. $1blprefix와 함께 사용해야 합니다.", diff --git a/includes/api/i18n/pt.json b/includes/api/i18n/pt.json index caa77c2221..62888307ca 100644 --- a/includes/api/i18n/pt.json +++ b/includes/api/i18n/pt.json @@ -835,6 +835,7 @@ "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-paramvalue-prop-varianttitles": "Fornece o título de apresentação em todas as variantes da língua de conteúdo da wiki.", "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 Main Page.", diff --git a/includes/api/i18n/zh-hans.json b/includes/api/i18n/zh-hans.json index fd19c73b9b..fba891b62f 100644 --- a/includes/api/i18n/zh-hans.json +++ b/includes/api/i18n/zh-hans.json @@ -849,6 +849,7 @@ "apihelp-query+info-paramvalue-prop-readable": "用户是否可以阅读此页面。", "apihelp-query+info-paramvalue-prop-preload": "提供由EditFormPreloadText返回的文本。", "apihelp-query+info-paramvalue-prop-displaytitle": "在页面标题实际显示的地方提供方式。", + "apihelp-query+info-paramvalue-prop-varianttitles": "提供网站内容语言所有变体的显示标题。", "apihelp-query+info-param-testactions": "测试当前用户是否可以在页面上执行某种操作。", "apihelp-query+info-param-token": "请改用[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]。", "apihelp-query+info-example-simple": "获取有关页面Main Page的信息。", diff --git a/includes/filerepo/file/LocalFile.php b/includes/filerepo/file/LocalFile.php index 7fc45ebf4a..0464f071b5 100644 --- a/includes/filerepo/file/LocalFile.php +++ b/includes/filerepo/file/LocalFile.php @@ -3344,9 +3344,9 @@ class LocalFileMoveBatch { __METHOD__, [ 'FOR UPDATE' ] ); - $oldRowCount = $dbw->selectField( + $oldRowCount = $dbw->selectRowCount( 'oldimage', - 'COUNT(*)', + '*', [ 'oi_name' => $this->oldName ], __METHOD__, [ 'FOR UPDATE' ] diff --git a/includes/installer/DatabaseUpdater.php b/includes/installer/DatabaseUpdater.php index 7a1aba636c..04132ad577 100644 --- a/includes/installer/DatabaseUpdater.php +++ b/includes/installer/DatabaseUpdater.php @@ -150,7 +150,7 @@ abstract class DatabaseUpdater { * LoadExtensionSchemaUpdates hook. */ private function loadExtensions() { - if ( !defined( 'MEDIAWIKI_INSTALL' ) ) { + if ( !defined( 'MEDIAWIKI_INSTALL' ) || defined( 'MW_EXTENSIONS_LOADED' ) ) { return; // already loaded } $vars = Installer::getExistingLocalSettings(); @@ -162,7 +162,7 @@ abstract class DatabaseUpdater { // This will automatically add "AutoloadClasses" to $wgAutoloadClasses $data = $registry->readFromQueue( $queue ); - $hooks = [ 'wgHooks' => [ 'LoadExtensionSchemaUpdates' => [] ] ]; + $hooks = []; if ( isset( $data['globals']['wgHooks']['LoadExtensionSchemaUpdates'] ) ) { $hooks = $data['globals']['wgHooks']['LoadExtensionSchemaUpdates']; } diff --git a/includes/installer/Installer.php b/includes/installer/Installer.php index 7cfc617333..94a5a5a474 100644 --- a/includes/installer/Installer.php +++ b/includes/installer/Installer.php @@ -1346,6 +1346,10 @@ abstract class Installer { $exts = $this->getVar( '_Extensions' ); $IP = $this->getVar( 'IP' ); + // Marker for DatabaseUpdater::loadExtensions so we don't + // double load extensions + define( 'MW_EXTENSIONS_LOADED', true ); + /** * We need to include DefaultSettings before including extensions to avoid * warnings about unset variables. However, the only thing we really diff --git a/includes/installer/i18n/el.json b/includes/installer/i18n/el.json index 6d1e3f1691..39c84cebd7 100644 --- a/includes/installer/i18n/el.json +++ b/includes/installer/i18n/el.json @@ -65,7 +65,9 @@ "config-memory-raised": "Το memory_limit της PHP είναι $1 και αυξήθηκε σε $2.", "config-memory-bad": "Προειδοποίηση: το memory_limit της PHP είναι $1.\nΑυτή η τιμή είναι πιθανώς πολύ χαμηλή.\n\nΗ εγκατάσταση ενδέχεται να αποτύχει!", "config-apc": "Το [http://www.php.net/apc APC] είναι εγκατεστημένο", + "config-apcu": "Το [http://www.php.net/apcu APCu] είναι εγκατεστημένο", "config-wincache": "[https://www.iis.net/download/WinCacheForPhp Το WinCache] είναι εγκατεστημένο", + "config-no-cache-apcu": "Προειδοποίηση: Αποτυχία εύρεσης του [http://www.php.net/apcu APCu] ή του [http://www.iis.net/download/WinCacheForPhp WinCache].\nΗ αρχειοθέτηση αντικειμένων δεν έχει ενεργοποιηθεί.", "config-diff3-bad": "Το GNU diff3 δεν βρέθηκε.", "config-git": "Βρέθηκε το λογισμικό ελέγχου εκδόσεων Git: $1.", "config-git-bad": "Το λογισμικό ελέγχου εκδόσεων Git δεν βρέθηκε.", @@ -271,6 +273,7 @@ "config-install-extension-tables": "Γίνεται δημιουργία πινάκων για τις εγκατεστημένες επεκτάσεις", "config-install-mainpage-failed": "Δεν ήταν δυνατή η εισαγωγή της αρχικής σελίδας: $1", "config-install-done": "Συγχαρητήρια!\nΈχετε εγκαταστήσει το MediaWiki.\n\nΤο πρόγραμμα εγκατάστασης έχει δημιουργήσει το αρχείο LocalSettings.php.\nΠεριέχει όλες τις ρυθμίσεις παραμέτρων σας.\n\nΘα πρέπει να το κατεβάσετε και να το βάλετε στη βάση της εγκατάστασης του wiki σας (στον ίδιο κατάλογο με το index.php). Η λήψη θα πρέπει να έχει ξεκινήσει αυτόματα.\n\nΕάν δεν σας προτάθηκε λήψη, ή αν την ακυρώσατε, μπορείτε να επανεκκινήσετε τη λήψη κάνοντας κλικ στο σύνδεσμο ακριβώς από κάτω:\n\n$3\n\nΣημείωση: Εάν δεν το κάνετε αυτό τώρα, αυτό το αρχείο ρύθμισης παραμέτρων δεν θα είναι διαθέσιμο για σας αργότερα αν βγείτε από την εγκατάσταση χωρίς να το κατεβάσετε!\n\nΌταν θα έχει γίνει αυτό, μπορείτε να [$2 μπείτε στο wiki σας].", + "config-install-done-path": "Συγχαρητήρια!\nΈχετε εγκαταστήσει το MediaWiki.\n\nΤο πρόγραμμα εγκατάστασης έχει δημιουργήσει το αρχείο LocalSettings.php.\nΠεριέχει όλες τις ρύθμιση σας.\n\nΘα πρέπει να το κατεβάσετε και να το βάλετε στο $4. Η λήψη θα πρέπει να έχει ξεκινήσει αυτόματα.\n\nΕάν δεν σας προτάθηκε λήψη, ή αν την ακυρώσατε, μπορείτε να επανεκκινήσετε τη λήψη κάνοντας κλικ στο σύνδεσμο ακριβώς από κάτω:\n\n$3\n\nΣημείωση: Εάν δεν το κάνετε αυτό τώρα, αυτό το δημιουγημένο αρχείο ρύθμισης δεν θα είναι διαθέσιμο για σας αργότερα αν βγείτε από την εγκατάσταση χωρίς να το κατεβάσετε!\n\nΌταν θα έχει γίνει αυτό, μπορείτε να [$2 μπείτε στο wiki σας].", "config-install-success": " Το σύστημα της MediaWiki έχει εγκατασταθεί με επιτυχία. Μπορείτε τώρα να επισκεφθείτε το \n <$1$2> για να δείτε το wiki σας.\nΑν έχετε ερωτήσεις, ελέγξετε την λίστα με τις πιο συχνές ερωτήσεις:\n ή χρησιμοποιήστε ένα από τα φόρουμ υποστήριξης που είναι συνδεδεμένα σε αυτήν την σελίδα.", "config-download-localsettings": "Λήψη του LocalSettings.php", "config-help": "βοήθεια", diff --git a/includes/libs/rdbms/database/AtomicSectionIdentifier.php b/includes/libs/rdbms/database/AtomicSectionIdentifier.php new file mode 100644 index 0000000000..c6e3d44c0d --- /dev/null +++ b/includes/libs/rdbms/database/AtomicSectionIdentifier.php @@ -0,0 +1,27 @@ +__call( __FUNCTION__, func_get_args() ); } - public function cancelAtomic( $fname = __METHOD__ ) { + public function cancelAtomic( $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null ) { return $this->__call( __FUNCTION__, func_get_args() ); } - public function doAtomicSection( $fname, callable $callback ) { + public function doAtomicSection( + $fname, callable $callback, $cancelable = self::ATOMIC_NOT_CANCELABLE + ) { return $this->__call( __FUNCTION__, func_get_args() ); } diff --git a/includes/libs/rdbms/database/Database.php b/includes/libs/rdbms/database/Database.php index 5b259bd31d..f6817950ed 100644 --- a/includes/libs/rdbms/database/Database.php +++ b/includes/libs/rdbms/database/Database.php @@ -212,7 +212,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware /** * Array of levels of atomicity within transactions * - * @var array + * @var array List of (name, unique ID, savepoint ID) */ private $trxAtomicLevels = []; /** @@ -898,34 +898,48 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( $this->conn ) { // Resolve any dangling transaction first if ( $this->trxLevel ) { - // Meaningful transactions should ideally have been resolved by now - if ( $this->writesOrCallbacksPending() ) { - $this->queryLogger->warning( - __METHOD__ . ": writes or callbacks still pending.", - [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] - ); + if ( $this->trxAtomicLevels ) { // Cannot let incomplete atomic sections be committed - if ( $this->trxAtomicLevels ) { - $levels = $this->flatAtomicSectionList(); - $exception = new DBUnexpectedError( - $this, - __METHOD__ . ": atomic sections $levels are still open." - ); - // Check if it is possible to properly commit and trigger callbacks - } elseif ( $this->trxEndCallbacksSuppressed ) { + $levels = $this->flatAtomicSectionList(); + $exception = new DBUnexpectedError( + $this, + __METHOD__ . ": atomic sections $levels are still open." + ); + } elseif ( $this->trxAutomatic ) { + // Only the connection manager can commit non-empty DBO_TRX transactions + if ( $this->writesOrCallbacksPending() ) { $exception = new DBUnexpectedError( $this, - __METHOD__ . ': callbacks are suppressed; cannot properly commit.' + __METHOD__ . + ": mass commit/rollback of peer transaction required (DBO_TRX set)." ); } + } elseif ( $this->trxLevel ) { + // Commit explicit transactions as if this was commit() + $this->queryLogger->warning( + __METHOD__ . ": writes or callbacks still pending.", + [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] + ); + } + + if ( $this->trxEndCallbacksSuppressed ) { + $exception = $exception ?: new DBUnexpectedError( + $this, + __METHOD__ . ': callbacks are suppressed; cannot properly commit.' + ); } + // Commit or rollback the changes and run any callbacks as needed if ( $this->trxStatus === self::STATUS_TRX_OK && !$exception ) { - $this->commit( __METHOD__, self::TRANSACTION_INTERNAL ); + $this->commit( + __METHOD__, + $this->trxAutomatic ? self::FLUSHING_INTERNAL : self::FLUSHING_ONE + ); } else { - $this->rollback( __METHOD__, self::TRANSACTION_INTERNAL ); + $this->rollback( __METHOD__, self::FLUSHING_INTERNAL ); } } + // Close the actual connection in the binding handle $closed = $this->closeConnection(); $this->conn = false; @@ -1293,7 +1307,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( $this->trxStatus < self::STATUS_TRX_OK ) { throw new DBTransactionStateError( $this, - "Cannot execute query from $fname while transaction status is ERROR. ", + "Cannot execute query from $fname while transaction status is ERROR.", [], $this->trxStatusCause ); @@ -3453,58 +3467,104 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->doSavepoint( $savepointId, $fname ); } - $this->trxAtomicLevels[] = [ $fname, $savepointId ]; + $sectionId = new AtomicSectionIdentifier; + $this->trxAtomicLevels[] = [ $fname, $sectionId, $savepointId ]; + + return $sectionId; } final public function endAtomic( $fname = __METHOD__ ) { - if ( !$this->trxLevel ) { - throw new DBUnexpectedError( $this, "No atomic transaction is open (got $fname)." ); + if ( !$this->trxLevel || !$this->trxAtomicLevels ) { + throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." ); } - list( $savedFname, $savepointId ) = $this->trxAtomicLevels - ? array_pop( $this->trxAtomicLevels ) : [ null, null ]; + // Check if the current section matches $fname + $pos = count( $this->trxAtomicLevels ) - 1; + list( $savedFname, , $savepointId ) = $this->trxAtomicLevels[$pos]; + if ( $savedFname !== $fname ) { - throw new DBUnexpectedError( $this, "Invalid atomic section ended (got $fname)." ); + throw new DBUnexpectedError( + $this, + "Invalid atomic section ended (got $fname but expected $savedFname)." + ); } + // Remove the last section and re-index the array + $this->trxAtomicLevels = array_slice( $this->trxAtomicLevels, 0, $pos ); + if ( !$this->trxAtomicLevels && $this->trxAutomaticAtomic ) { $this->commit( $fname, self::FLUSHING_INTERNAL ); - } elseif ( $savepointId && $savepointId !== 'n/a' ) { + } elseif ( $savepointId !== null && $savepointId !== 'n/a' ) { $this->doReleaseSavepoint( $savepointId, $fname ); } } - final public function cancelAtomic( $fname = __METHOD__ ) { - if ( !$this->trxLevel ) { - throw new DBUnexpectedError( $this, "No atomic transaction is open (got $fname)." ); + final public function cancelAtomic( + $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null + ) { + if ( !$this->trxLevel || !$this->trxAtomicLevels ) { + throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." ); } - list( $savedFname, $savepointId ) = $this->trxAtomicLevels - ? array_pop( $this->trxAtomicLevels ) : [ null, null ]; - if ( $savedFname !== $fname ) { - throw new DBUnexpectedError( $this, "Invalid atomic section ended (got $fname)." ); + if ( $sectionId !== null ) { + // Find the (last) section with the given $sectionId + $pos = -1; + foreach ( $this->trxAtomicLevels as $i => list( $asFname, $asId, $spId ) ) { + if ( $asId === $sectionId ) { + $pos = $i; + } + } + if ( $pos < 0 ) { + throw new DBUnexpectedError( "Atomic section not found (for $fname)" ); + } + // Remove all descendant sections and re-index the array + $this->trxAtomicLevels = array_slice( $this->trxAtomicLevels, 0, $pos + 1 ); } - if ( !$savepointId ) { - throw new DBUnexpectedError( $this, "Uncancelable atomic section canceled (got $fname)." ); + + // Check if the current section matches $fname + $pos = count( $this->trxAtomicLevels ) - 1; + list( $savedFname, , $savepointId ) = $this->trxAtomicLevels[$pos]; + + if ( $savedFname !== $fname ) { + throw new DBUnexpectedError( + $this, + "Invalid atomic section ended (got $fname but expected $savedFname)." + ); } - if ( !$this->trxAtomicLevels && $this->trxAutomaticAtomic ) { - $this->rollback( $fname, self::FLUSHING_INTERNAL ); - } elseif ( $savepointId !== 'n/a' ) { - $this->doRollbackToSavepoint( $savepointId, $fname ); - $this->trxStatus = self::STATUS_TRX_OK; // no exception; recovered - $this->trxStatusIgnoredCause = null; + // Remove the last section and re-index the array + $this->trxAtomicLevels = array_slice( $this->trxAtomicLevels, 0, $pos ); + + if ( $savepointId !== null ) { + // Rollback the transaction to the state just before this atomic section + if ( $savepointId === 'n/a' ) { + $this->rollback( $fname, self::FLUSHING_INTERNAL ); + } else { + $this->doRollbackToSavepoint( $savepointId, $fname ); + $this->trxStatus = self::STATUS_TRX_OK; // no exception; recovered + $this->trxStatusIgnoredCause = null; + } + } elseif ( $this->trxStatus > self::STATUS_TRX_ERROR ) { + // Put the transaction into an error state if it's not already in one + $this->trxStatus = self::STATUS_TRX_ERROR; + $this->trxStatusCause = new DBUnexpectedError( + $this, + "Uncancelable atomic section canceled (got $fname)." + ); } $this->affectedRowCount = 0; // for the sake of consistency } - final public function doAtomicSection( $fname, callable $callback ) { - $this->startAtomic( $fname, self::ATOMIC_CANCELABLE ); + final public function doAtomicSection( + $fname, callable $callback, $cancelable = self::ATOMIC_NOT_CANCELABLE + ) { + $sectionId = $this->startAtomic( $fname, $cancelable ); try { $res = call_user_func_array( $callback, [ $this, $fname ] ); } catch ( Exception $e ) { - $this->cancelAtomic( $fname ); + $this->cancelAtomic( $fname, $sectionId ); + throw $e; } $this->endAtomic( $fname ); @@ -3513,6 +3573,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } final public function begin( $fname = __METHOD__, $mode = self::TRANSACTION_EXPLICIT ) { + static $modes = [ self::TRANSACTION_EXPLICIT, self::TRANSACTION_INTERNAL ]; + if ( !in_array( $mode, $modes, true ) ) { + throw new DBUnexpectedError( $this, "$fname: invalid mode parameter '$mode'." ); + } + // Protect against mismatched atomic section, transaction nesting, and snapshot loss if ( $this->trxLevel ) { if ( $this->trxAtomicLevels ) { @@ -3571,9 +3636,14 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxLevel = 1; } - final public function commit( $fname = __METHOD__, $flush = '' ) { + final public function commit( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) { + static $modes = [ self::FLUSHING_ONE, self::FLUSHING_ALL_PEERS, self::FLUSHING_INTERNAL ]; + if ( !in_array( $flush, $modes, true ) ) { + throw new DBUnexpectedError( $this, "$fname: invalid flush parameter '$flush'." ); + } + if ( $this->trxLevel && $this->trxAtomicLevels ) { - // There are still atomic sections open. This cannot be ignored + // There are still atomic sections open; this cannot be ignored $levels = $this->flatAtomicSectionList(); throw new DBUnexpectedError( $this, diff --git a/includes/libs/rdbms/database/DatabaseMysqlBase.php b/includes/libs/rdbms/database/DatabaseMysqlBase.php index 1624122b7f..3e6190ce9f 100644 --- a/includes/libs/rdbms/database/DatabaseMysqlBase.php +++ b/includes/libs/rdbms/database/DatabaseMysqlBase.php @@ -933,18 +933,23 @@ abstract class DatabaseMysqlBase extends Database { } // Wait on the GTID set (MariaDB only) $gtidArg = $this->addQuotes( implode( ',', $gtidsWait ) ); - $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" ); + if ( strpos( $gtidArg, ':' ) !== false ) { + // MySQL GTIDs, e.g "source_id:transaction_id" + $res = $this->doQuery( "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)" ); + } else { + // MariaDB GTIDs, e.g."domain:server:sequence" + $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" ); + } } else { // Wait on the binlog coordinates $encFile = $this->addQuotes( $pos->getLogFile() ); - $encPos = intval( $pos->pos[1] ); + $encPos = intval( $pos->getLogPosition()[$pos::CORD_EVENT] ); $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()}" ); + throw new DBExpectedError( $this, "Replication wait failed: {$this->lastError()}" ); } // Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual @@ -976,21 +981,23 @@ abstract class DatabaseMysqlBase extends Database { * @return MySQLMasterPos|bool */ public function getReplicaPos() { - $now = microtime( true ); - - if ( $this->useGTIDs ) { - $res = $this->query( "SELECT @@global.gtid_slave_pos AS Value", __METHOD__ ); - $gtidRow = $this->fetchObject( $res ); - if ( $gtidRow && strlen( $gtidRow->Value ) ) { - return new MySQLMasterPos( $gtidRow->Value, $now ); + $now = microtime( true ); // as-of-time *before* fetching GTID variables + + if ( $this->useGTIDs() ) { + // Try to use GTIDs, fallbacking to binlog positions if not possible + $data = $this->getServerGTIDs( __METHOD__ ); + // Use gtid_slave_pos for MariaDB and gtid_executed for MySQL + foreach ( [ 'gtid_slave_pos', 'gtid_executed' ] as $name ) { + if ( isset( $data[$name] ) && strlen( $data[$name] ) ) { + return new MySQLMasterPos( $data[$name], $now ); + } } } - $res = $this->query( 'SHOW SLAVE STATUS', __METHOD__ ); - $row = $this->fetchObject( $res ); - if ( $row && strlen( $row->Relay_Master_Log_File ) ) { + $data = $this->getServerRoleStatus( 'SLAVE', __METHOD__ ); + if ( $data && strlen( $data['Relay_Master_Log_File'] ) ) { return new MySQLMasterPos( - "{$row->Relay_Master_Log_File}/{$row->Exec_Master_Log_Pos}", + "{$data['Relay_Master_Log_File']}/{$data['Exec_Master_Log_Pos']}", $now ); } @@ -1004,23 +1011,97 @@ abstract class DatabaseMysqlBase extends Database { * @return MySQLMasterPos|bool */ public function getMasterPos() { - $now = microtime( true ); + $now = microtime( true ); // as-of-time *before* fetching GTID variables + + $pos = false; + if ( $this->useGTIDs() ) { + // Try to use GTIDs, fallbacking to binlog positions if not possible + $data = $this->getServerGTIDs( __METHOD__ ); + // Use gtid_binlog_pos for MariaDB and gtid_executed for MySQL + foreach ( [ 'gtid_binlog_pos', 'gtid_executed' ] as $name ) { + if ( isset( $data[$name] ) && strlen( $data[$name] ) ) { + $pos = new MySQLMasterPos( $data[$name], $now ); + break; + } + } + // Filter domains that are inactive or not relevant to the session + if ( $pos ) { + $pos->setActiveOriginServerId( $this->getServerId() ); + $pos->setActiveOriginServerUUID( $this->getServerUUID() ); + if ( isset( $data['gtid_domain_id'] ) ) { + $pos->setActiveDomain( $data['gtid_domain_id'] ); + } + } + } - if ( $this->useGTIDs ) { - $res = $this->query( "SELECT @@global.gtid_binlog_pos AS Value", __METHOD__ ); - $gtidRow = $this->fetchObject( $res ); - if ( $gtidRow && strlen( $gtidRow->Value ) ) { - return new MySQLMasterPos( $gtidRow->Value, $now ); + if ( !$pos ) { + $data = $this->getServerRoleStatus( 'MASTER', __METHOD__ ); + if ( $data && strlen( $data['File'] ) ) { + $pos = new MySQLMasterPos( "{$data['File']}/{$data['Position']}", $now ); } } - $res = $this->query( 'SHOW MASTER STATUS', __METHOD__ ); - $row = $this->fetchObject( $res ); - if ( $row && strlen( $row->File ) ) { - return new MySQLMasterPos( "{$row->File}/{$row->Position}", $now ); + return $pos; + } + + /** + * @return int + * @throws DBQueryError If the variable doesn't exist for some reason + */ + protected function getServerId() { + return $this->srvCache->getWithSetCallback( + $this->srvCache->makeGlobalKey( 'mysql-server-id', $this->getServer() ), + self::SERVER_ID_CACHE_TTL, + function () { + $res = $this->query( "SELECT @@server_id AS id", __METHOD__ ); + return intval( $this->fetchObject( $res )->id ); + } + ); + } + + /** + * @return string|null + */ + protected function getServerUUID() { + return $this->srvCache->getWithSetCallback( + $this->srvCache->makeGlobalKey( 'mysql-server-uuid', $this->getServer() ), + self::SERVER_ID_CACHE_TTL, + function () { + $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'server_uuid'" ); + $row = $this->fetchObject( $res ); + + return $row ? $row->Value : null; + } + ); + } + + /** + * @param string $fname + * @return string[] + */ + protected function getServerGTIDs( $fname = __METHOD__ ) { + $map = []; + // Get global-only variables like gtid_executed + $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_%'", $fname ); + foreach ( $res as $row ) { + $map[$row->Variable_name] = $row->Value; + } + // Get session-specific (e.g. gtid_domain_id since that is were writes will log) + $res = $this->query( "SHOW SESSION VARIABLES LIKE 'gtid_%'", $fname ); + foreach ( $res as $row ) { + $map[$row->Variable_name] = $row->Value; } - return false; + return $map; + } + + /** + * @param string $role One of "MASTER"/"SLAVE" + * @param string $fname + * @return string[] Latest available server status row + */ + protected function getServerRoleStatus( $role, $fname = __METHOD__ ) { + return $this->query( "SHOW $role STATUS", $fname )->fetchRow() ?: []; } public function serverIsReadOnly() { @@ -1485,6 +1566,12 @@ abstract class DatabaseMysqlBase extends Database { return 'CAST( ' . $field . ' AS SIGNED )'; } + /* + * @return bool Whether GTID support is used (mockable for testing) + */ + protected function useGTIDs() { + return $this->useGTIDs; + } } class_alias( DatabaseMysqlBase::class, 'DatabaseMysqlBase' ); diff --git a/includes/libs/rdbms/database/IDatabase.php b/includes/libs/rdbms/database/IDatabase.php index 5876d6bab0..b50ff70617 100644 --- a/includes/libs/rdbms/database/IDatabase.php +++ b/includes/libs/rdbms/database/IDatabase.php @@ -22,7 +22,6 @@ namespace Wikimedia\Rdbms; use InvalidArgumentException; use Wikimedia\ScopedCallback; use RuntimeException; -use UnexpectedValueException; use stdClass; /** @@ -54,9 +53,11 @@ interface IDatabase { /** @var string Atomic section is cancelable */ const ATOMIC_CANCELABLE = 'cancelable'; - /** @var string Transaction operation comes from service managing all DBs */ + /** @var string Commit/rollback is from outside the IDatabase handle and connection manager */ + const FLUSHING_ONE = ''; + /** @var string Commit/rollback is from the connection manager for the IDatabase handle */ const FLUSHING_ALL_PEERS = 'flush'; - /** @var string Transaction operation comes from the database class internally */ + /** @var string Commit/rollback is from the IDatabase handle internally */ const FLUSHING_INTERNAL = 'flush'; /** @var string Do not remember the prior flags */ @@ -1472,11 +1473,13 @@ interface IDatabase { /** * 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 + * AUTOCOMMIT mode unless there are begin() calls. Callbacks must commit any transactions * that they begin. * * This is useful for combining cooperative locks and DB transactions. * + * @note: do not assume that *other* IDatabase instances will be AUTOCOMMIT mode + * * The callback takes one argument: * - How the transaction ended (IDatabase::TRIGGER_COMMIT or IDatabase::TRIGGER_ROLLBACK) * @@ -1495,7 +1498,7 @@ interface IDatabase { * of the round, just after all peer transactions COMMIT. If the transaction round * is rolled back, then the callback is cancelled. * - * Queries in the function will run in AUTO-COMMIT mode unless there are begin() calls. + * Queries in the function will run in AUTOCOMMIT 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. @@ -1506,6 +1509,8 @@ interface IDatabase { * * Updates will execute in the order they were enqueued. * + * @note: do not assume that *other* IDatabase instances will be AUTOCOMMIT mode + * * The callback takes one argument: * - How the transaction ended (IDatabase::TRIGGER_COMMIT or IDatabase::TRIGGER_IDLE) * @@ -1555,26 +1560,77 @@ interface IDatabase { public function setTransactionListener( $name, callable $callback = null ); /** - * Begin an atomic section of statements + * Begin an atomic section of SQL statements + * + * Start an implicit transaction if no transaction is already active, set a savepoint + * (if $cancelable is ATOMIC_CANCELABLE), and track the given section name to enforce + * that the transaction is not committed prematurely. The end of the section must be + * signified exactly once, either by endAtomic() or cancelAtomic(). Sections can have + * have layers of inner sections (sub-sections), but all sections must be ended in order + * of innermost to outermost. Transactions cannot be started or committed until all + * atomic sections are closed. * - * If a transaction has been started already, (optionally) sets a savepoint - * and tracks 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, one is started implicitly. + * ATOMIC_CANCELABLE is useful when the caller needs to handle specific failure cases + * by discarding the section's writes. This should not be used for failures when: + * - upsert() could easily be used instead + * - insert() with IGNORE could easily be used instead + * - select() with FOR UPDATE could be checked before issuing writes instead + * - The failure is from code that runs after the first write but doesn't need to + * - The failures are from contention solvable via onTransactionPreCommitOrIdle() + * - The failures are deadlocks; the RDBMs usually discard the whole transaction * - * 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. + * @note: callers must use additional measures for situations involving two or more + * (peer) transactions (e.g. updating two database servers at once). The transaction + * and savepoint logic of this method only applies to this specific IDatabase instance. * - * All atomic levels *must* be explicitly closed using IDatabase::endAtomic() - * or IDatabase::cancelAtomic(), 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. + * Example usage: + * @code + * // Start a transaction if there isn't one already + * $dbw->startAtomic( __METHOD__ ); + * // Serialize these thread table updates + * $dbw->select( 'thread', '1', [ 'td_id' => $tid ], __METHOD__, 'FOR UPDATE' ); + * // Add a new comment for the thread + * $dbw->insert( 'comment', $row, __METHOD__ ); + * $cid = $db->insertId(); + * // Update thread reference to last comment + * $dbw->update( 'thread', [ 'td_latest' => $cid ], [ 'td_id' => $tid ], __METHOD__ ); + * // Demark the end of this conceptual unit of updates + * $dbw->endAtomic( __METHOD__ ); + * @endcode + * + * Example usage (atomic changes that might have to be discarded): + * @code + * // Start a transaction if there isn't one already + * $dbw->startAtomic( __METHOD__, $dbw::ATOMIC_CANCELABLE ); + * // Create new record metadata row + * $dbw->insert( 'records', $row, __METHOD__ ); + * // Figure out where to store the data based on the new row's ID + * $path = $recordDirectory . '/' . $dbw->insertId(); + * // Write the record data to the storage system + * $status = $fileBackend->create( [ 'dst' => $path, 'content' => $data ] ); + * if ( $status->isOK() ) { + * // Try to cleanup files orphaned by transaction rollback + * $dbw->onTransactionResolution( + * function ( $type ) use ( $fileBackend, $path ) { + * if ( $type === IDatabase::TRIGGER_ROLLBACK ) { + * $fileBackend->delete( [ 'src' => $path ] ); + * } + * }, + * __METHOD__ + * ); + * // Demark the end of this conceptual unit of updates + * $dbw->endAtomic( __METHOD__ ); + * } else { + * // Discard these writes from the transaction (preserving prior writes) + * $dbw->cancelAtomic( __METHOD__ ); + * } + * @endcode * * @since 1.23 * @param string $fname * @param string $cancelable Pass self::ATOMIC_CANCELABLE to use a * savepoint and enable self::cancelAtomic() for this section. + * @return AtomicSectionIdentifier section ID token * @throws DBError */ public function startAtomic( $fname = __METHOD__, $cancelable = self::ATOMIC_NOT_CANCELABLE ); @@ -1602,33 +1658,80 @@ interface IDatabase { * corresponding startAtomic() implicitly started a transaction, that * transaction is rolled back. * - * Note that a call to IDatabase::rollback() will also roll back any open - * atomic sections. + * @note: callers must use additional measures for situations involving two or more + * (peer) transactions (e.g. updating two database servers at once). The transaction + * and savepoint logic of startAtomic() are bound to specific IDatabase instances. + * + * Note that a call to IDatabase::rollback() will also roll back any open atomic sections. * * @note As a micro-optimization to save a few DB calls, this method may only * be called when startAtomic() was called with the ATOMIC_CANCELABLE flag. * @since 1.31 * @see IDatabase::startAtomic * @param string $fname + * @param AtomicSectionIdentifier $sectionId Section ID from startAtomic(); + * passing this enables cancellation of unclosed nested sections [optional] * @throws DBError */ - public function cancelAtomic( $fname = __METHOD__ ); + public function cancelAtomic( $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null ); /** - * Run a callback to do an atomic set of updates for this database + * Perform an atomic section of reversable SQL statements from a callback * * The $callback takes the following arguments: * - This database object * - The value of $fname * - * If any exception occurs in the callback, then cancelAtomic() will be - * called to back out any statements executed by the callback and the error - * will be re-thrown. It may also be that the cancel 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. + * This will execute the callback inside a pair of startAtomic()/endAtomic() calls. + * If any exception occurs during execution of the callback, it will be handled as follows: + * - If $cancelable is ATOMIC_CANCELABLE, cancelAtomic() will be called to back out any + * (and only) statements executed during the atomic section. If that succeeds, then the + * exception will be re-thrown; if it fails, then a different exception will be thrown + * and any further query attempts will fail until rollback() is called. + * - If $cancelable is ATOMIC_NOT_CANCELABLE, cancelAtomic() will be called to mark the + * end of the section and the error will be re-thrown. Any further query attempts will + * fail until rollback() is called. + * + * This method is convenient for letting calls to the caller of this method be wrapped + * in a try/catch blocks for exception types that imply that the caller failed but was + * able to properly discard the changes it made in the transaction. This method can be + * an alternative to explicit calls to startAtomic()/endAtomic()/cancelAtomic(). + * + * Example usage, "RecordStore::save" method: + * @code + * $dbw->doAtomicSection( __METHOD__, function ( $dbw ) use ( $record ) { + * // Create new record metadata row + * $dbw->insert( 'records', $record->toArray(), __METHOD__ ); + * // Figure out where to store the data based on the new row's ID + * $path = $this->recordDirectory . '/' . $dbw->insertId(); + * // Write the record data to the storage system; + * // blob store throughs StoreFailureException on failure + * $this->blobStore->create( $path, $record->getJSON() ); + * // Try to cleanup files orphaned by transaction rollback + * $dbw->onTransactionResolution( + * function ( $type ) use ( $path ) { + * if ( $type === IDatabase::TRIGGER_ROLLBACK ) { + * $this->blobStore->delete( $path ); + * } + * }, + * }, + * __METHOD__ + * ); + * @endcode * - * This can be an alternative to explicit startAtomic()/endAtomic()/cancelAtomic() calls. + * Example usage, caller of the "RecordStore::save" method: + * @code + * $dbw->startAtomic( __METHOD__ ); + * // ...various SQL writes happen... + * try { + * $recordStore->save( $record ); + * } catch ( StoreFailureException $e ) { + * $dbw->cancelAtomic( __METHOD__ ); + * // ...various SQL writes happen... + * } + * // ...various SQL writes happen... + * $dbw->endAtomic( __METHOD__ ); + * @endcode * * @see Database::startAtomic * @see Database::endAtomic @@ -1636,15 +1739,18 @@ interface IDatabase { * * @param string $fname Caller name (usually __METHOD__) * @param callable $callback Callback that issues DB updates + * @param string $cancelable Pass self::ATOMIC_CANCELABLE to use a + * savepoint and enable self::cancelAtomic() for this section. * @return mixed $res Result of the callback (since 1.28) * @throws DBError * @throws RuntimeException - * @throws UnexpectedValueException * @since 1.27; prior to 1.31 this did a rollback() instead of * cancelAtomic(), and assumed no callers up the stack would ever try to * catch the exception. */ - public function doAtomicSection( $fname, callable $callback ); + public function doAtomicSection( + $fname, callable $callback, $cancelable = self::ATOMIC_NOT_CANCELABLE + ); /** * Begin a transaction. If a transaction is already in progress, @@ -1779,7 +1885,7 @@ interface IDatabase { * 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 + * safe to be pessimistic. In AUTOCOMMIT 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) diff --git a/includes/libs/rdbms/database/position/MySQLMasterPos.php b/includes/libs/rdbms/database/position/MySQLMasterPos.php index cdcb79cde6..54eca79a44 100644 --- a/includes/libs/rdbms/database/position/MySQLMasterPos.php +++ b/includes/libs/rdbms/database/position/MySQLMasterPos.php @@ -12,16 +12,36 @@ use UnexpectedValueException; * - 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). + * + * @see https://mariadb.com/kb/en/library/gtid/ + * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html */ class MySQLMasterPos implements DBMasterPos { - /** @var string|null Binlog file base name */ - public $binlog; - /** @var int[]|null Binglog file position tuple */ - public $pos; - /** @var string[] GTID list */ - public $gtids = []; + /** @var int One of (BINARY_LOG, GTID_MYSQL, GTID_MARIA) */ + private $style; + /** @var string|null Base name of all Binary Log files */ + private $binLog; + /** @var int[]|null Binary Log position tuple (index number, event number) */ + private $logPos; + /** @var string[] Map of (server_uuid/gtid_domain_id => GTID) */ + private $gtids = []; + /** @var int|null Active GTID domain ID */ + private $activeDomain; + /** @var int|null ID of the server were DB writes originate */ + private $activeServerId; + /** @var string|null UUID of the server were DB writes originate */ + private $activeServerUUID; /** @var float UNIX timestamp */ - public $asOfTime = 0.0; + private $asOfTime = 0.0; + + const BINARY_LOG = 'binary-log'; + const GTID_MARIA = 'gtid-maria'; + const GTID_MYSQL = 'gtid-mysql'; + + /** @var int Key name of the binary log index number of a position tuple */ + const CORD_INDEX = 0; + /** @var int Key name of the binary log event number of a position tuple */ + const CORD_EVENT = 1; /** * @param string $position One of (comma separated GTID list, /) @@ -38,18 +58,38 @@ class MySQLMasterPos implements DBMasterPos { protected function init( $position, $asOfTime ) { $m = []; if ( preg_match( '!^(.+)\.(\d+)/(\d+)$!', $position, $m ) ) { - $this->binlog = $m[1]; // ideally something like host name - $this->pos = [ (int)$m[2], (int)$m[3] ]; + $this->binLog = $m[1]; // ideally something like host name + $this->logPos = [ self::CORD_INDEX => (int)$m[2], self::CORD_EVENT => (int)$m[3] ]; + $this->style = self::BINARY_LOG; } else { $gtids = array_filter( array_map( 'trim', explode( ',', $position ) ) ); foreach ( $gtids as $gtid ) { - if ( !self::parseGTID( $gtid ) ) { + $components = self::parseGTID( $gtid ); + if ( !$components ) { throw new InvalidArgumentException( "Invalid GTID '$gtid'." ); } - $this->gtids[] = $gtid; + + list( $domain, $pos ) = $components; + if ( isset( $this->gtids[$domain] ) ) { + // For MySQL, handle the case where some past issue caused a gap in the + // executed GTID set, e.g. [last_purged+1,N-1] and [N+1,N+2+K]. Ignore the + // gap by using the GTID with the highest ending sequence number. + list( , $otherPos ) = self::parseGTID( $this->gtids[$domain] ); + if ( $pos > $otherPos ) { + $this->gtids[$domain] = $gtid; + } + } else { + $this->gtids[$domain] = $gtid; + } + + if ( is_int( $domain ) ) { + $this->style = self::GTID_MARIA; // gtid_domain_id + } else { + $this->style = self::GTID_MYSQL; // server_uuid + } } if ( !$this->gtids ) { - throw new InvalidArgumentException( "Got empty GTID set." ); + throw new InvalidArgumentException( "GTID set cannot be empty." ); } } @@ -66,8 +106,8 @@ class MySQLMasterPos implements DBMasterPos { } // Prefer GTID comparisons, which work with multi-tier replication - $thisPosByDomain = $this->getGtidCoordinates(); - $thatPosByDomain = $pos->getGtidCoordinates(); + $thisPosByDomain = $this->getActiveGtidCoordinates(); + $thatPosByDomain = $pos->getActiveGtidCoordinates(); if ( $thisPosByDomain && $thatPosByDomain ) { $comparisons = []; // Check that this has positions reaching those in $pos for all domains in common @@ -100,8 +140,8 @@ class MySQLMasterPos implements DBMasterPos { } // Prefer GTID comparisons, which work with multi-tier replication - $thisPosDomains = array_keys( $this->getGtidCoordinates() ); - $thatPosDomains = array_keys( $pos->getGtidCoordinates() ); + $thisPosDomains = array_keys( $this->getActiveGtidCoordinates() ); + $thatPosDomains = array_keys( $pos->getActiveGtidCoordinates() ); if ( $thisPosDomains && $thatPosDomains ) { // Check that $this has a GTID for at least one domain also in $pos; due to MariaDB // quirks, prior master switch-overs may result in inactive garbage GTIDs that cannot @@ -118,74 +158,119 @@ class MySQLMasterPos implements DBMasterPos { } /** - * @return string|null + * @return string|null Base name of binary log files + * @since 1.31 + */ + public function getLogName() { + return $this->gtids ? null : $this->binLog; + } + + /** + * @return int[]|null Tuple of (binary log file number, event number) + * @since 1.31 + */ + public function getLogPosition() { + return $this->gtids ? null : $this->logPos; + } + + /** + * @return string|null Name of the binary log file for this position + * @since 1.31 */ public function getLogFile() { - return $this->gtids ? null : "{$this->binlog}.{$this->pos[0]}"; + return $this->gtids ? null : "{$this->binLog}.{$this->logPos[self::CORD_INDEX]}"; } /** - * @return string[] + * @return string[] Map of (server_uuid/gtid_domain_id => GTID) + * @since 1.31 */ public function getGTIDs() { return $this->gtids; } /** - * @return string GTID set or / (e.g db1034-bin.000976/843431247) + * @param int|null $id @@gtid_domain_id of the active replication stream + * @since 1.31 */ - public function __toString() { - return $this->gtids - ? implode( ',', $this->gtids ) - : $this->getLogFile() . "/{$this->pos[1]}"; + public function setActiveDomain( $id ) { + $this->activeDomain = (int)$id; + } + + /** + * @param int|null $id @@server_id of the server were writes originate + * @since 1.31 + */ + public function setActiveOriginServerId( $id ) { + $this->activeServerId = (int)$id; + } + + /** + * @param string|null $id @@server_uuid of the server were writes originate + * @since 1.31 + */ + public function setActiveOriginServerUUID( $id ) { + $this->activeServerUUID = $id; } /** * @param MySQLMasterPos $pos * @param MySQLMasterPos $refPos * @return string[] List of GTIDs from $pos that have domains in $refPos + * @since 1.31 */ public static function getCommonDomainGTIDs( MySQLMasterPos $pos, MySQLMasterPos $refPos ) { - $gtidsCommon = []; - - $relevantDomains = $refPos->getGtidCoordinates(); // (domain => unused) - foreach ( $pos->gtids as $gtid ) { - list( $domain ) = self::parseGTID( $gtid ); - if ( isset( $relevantDomains[$domain] ) ) { - $gtidsCommon[] = $gtid; - } - } - - return $gtidsCommon; + return array_values( + array_intersect_key( $pos->gtids, $refPos->getActiveGtidCoordinates() ) + ); } /** * @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); possibly empty + * @return array Map of (server_uuid/gtid_domain_id => integer position); possibly empty */ - protected function getGtidCoordinates() { + protected function getActiveGtidCoordinates() { $gtidInfos = []; - foreach ( $this->gtids as $gtid ) { - list( $domain, $pos ) = self::parseGTID( $gtid ); - $gtidInfos[$domain] = $pos; + + foreach ( $this->gtids as $domain => $gtid ) { + list( $domain, $pos, $server ) = self::parseGTID( $gtid ); + + $ignore = false; + // Filter out GTIDs from non-active replication domains + if ( $this->style === self::GTID_MARIA && $this->activeDomain !== null ) { + $ignore |= ( $domain !== $this->activeDomain ); + } + // Likewise for GTIDs from non-active replication origin servers + if ( $this->style === self::GTID_MARIA && $this->activeServerId !== null ) { + $ignore |= ( $server !== $this->activeServerId ); + } elseif ( $this->style === self::GTID_MYSQL && $this->activeServerUUID !== null ) { + $ignore |= ( $server !== $this->activeServerUUID ); + } + + if ( !$ignore ) { + $gtidInfos[$domain] = $pos; + } } return $gtidInfos; } /** - * @param string $gtid - * @return array|null [domain, integer position] or null + * @param string $id GTID + * @return array|null [domain ID or server UUID, sequence number, server ID/UUID] or null */ - protected static function parseGTID( $gtid ) { + protected static function parseGTID( $id ) { $m = []; - if ( preg_match( '!^(\d+)-\d+-(\d+)$!', $gtid, $m ) ) { + if ( preg_match( '!^(\d+)-(\d+)-(\d+)$!', $id, $m ) ) { // MariaDB style: -- - return [ (int)$m[1], (int)$m[2] ]; - } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(\d+)$!', $gtid, $m ) ) { - // MySQL style: : - return [ $m[1], (int)$m[2] ]; + return [ (int)$m[1], (int)$m[3], (int)$m[2] ]; + } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(?:\d+-|)(\d+)$!', $id, $m ) ) { + // MySQL style: :- + // Normally, the first number should reflect the point (gtid_purged) where older + // binary logs where purged to save space. When doing comparisons, it may as well + // be 1 in that case. Assume that this is generally the situation. + return [ $m[1], (int)$m[2], $m[1] ]; } return null; @@ -194,16 +279,22 @@ class MySQLMasterPos implements DBMasterPos { /** * @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 + * @return array|bool Map of (binlog:, pos:(, )) or false */ protected function getBinlogCoordinates() { - return ( $this->binlog !== null && $this->pos !== null ) - ? [ 'binlog' => $this->binlog, 'pos' => $this->pos ] + return ( $this->binLog !== null && $this->logPos !== null ) + ? [ 'binlog' => $this->binLog, 'pos' => $this->logPos ] : false; } public function serialize() { - return serialize( [ 'position' => $this->__toString(), 'asOfTime' => $this->asOfTime ] ); + return serialize( [ + 'position' => $this->__toString(), + 'activeDomain' => $this->activeDomain, + 'activeServerId' => $this->activeServerId, + 'activeServerUUID' => $this->activeServerUUID, + 'asOfTime' => $this->asOfTime + ] ); } public function unserialize( $serialized ) { @@ -213,5 +304,23 @@ class MySQLMasterPos implements DBMasterPos { } $this->init( $data['position'], $data['asOfTime'] ); + if ( isset( $data['activeDomain'] ) ) { + $this->setActiveDomain( $data['activeDomain'] ); + } + if ( isset( $data['activeServerId'] ) ) { + $this->setActiveOriginServerId( $data['activeServerId'] ); + } + if ( isset( $data['activeServerUUID'] ) ) { + $this->setActiveOriginServerUUID( $data['activeServerUUID'] ); + } + } + + /** + * @return string GTID set or / (e.g db1034-bin.000976/843431247) + */ + public function __toString() { + return $this->gtids + ? implode( ',', $this->gtids ) + : $this->getLogFile() . "/{$this->logPos[self::CORD_EVENT]}"; } } diff --git a/includes/libs/rdbms/lbfactory/LBFactory.php b/includes/libs/rdbms/lbfactory/LBFactory.php index b1ea8104c7..19b8fdc238 100644 --- a/includes/libs/rdbms/lbfactory/LBFactory.php +++ b/includes/libs/rdbms/lbfactory/LBFactory.php @@ -131,7 +131,9 @@ abstract class LBFactory implements ILBFactory { 'ChronologyPositionIndex' => isset( $_GET['cpPosIndex'] ) ? $_GET['cpPosIndex'] : null ]; - $this->cliMode = isset( $conf['cliMode'] ) ? $conf['cliMode'] : PHP_SAPI === 'cli'; + $this->cliMode = isset( $conf['cliMode'] ) + ? $conf['cliMode'] + : ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' ); $this->hostname = isset( $conf['hostname'] ) ? $conf['hostname'] : gethostname(); $this->agent = isset( $conf['agent'] ) ? $conf['agent'] : ''; diff --git a/includes/libs/rdbms/loadbalancer/LoadBalancer.php b/includes/libs/rdbms/loadbalancer/LoadBalancer.php index db2ab1f29b..94acc1ef05 100644 --- a/includes/libs/rdbms/loadbalancer/LoadBalancer.php +++ b/includes/libs/rdbms/loadbalancer/LoadBalancer.php @@ -539,7 +539,7 @@ class LoadBalancer implements ILoadBalancer { if ( $this->loads[$i] > 0 ) { $start = microtime( true ); $ok = $this->doWait( $i, true, $timeout ) && $ok; - $timeout -= ( microtime( true ) - $start ); + $timeout -= intval( microtime( true ) - $start ); if ( $timeout <= 0 ) { break; // timeout reached } diff --git a/includes/page/WikiPage.php b/includes/page/WikiPage.php index f45036c1db..f3860c63f5 100644 --- a/includes/page/WikiPage.php +++ b/includes/page/WikiPage.php @@ -2871,13 +2871,32 @@ class WikiPage implements Page, IDBAccessObject { // In the future, we may keep revisions and mark them with // the rev_deleted field, which is reserved for this purpose. + // Lock rows in `revision` and its temp tables, but not any others. + // Note array_intersect() preserves keys from the first arg, and we're + // assuming $revQuery has `revision` primary and isn't using subtables + // for anything we care about. + $res = $dbw->select( + array_intersect( + $revQuery['tables'], + [ 'revision', 'revision_comment_temp', 'revision_actor_temp' ] + ), + '1', + [ 'rev_page' => $id ], + __METHOD__, + 'FOR UPDATE', + $revQuery['joins'] + ); + foreach ( $res as $row ) { + // Fetch all rows in case the DB needs that to properly lock them. + } + // Get all of the page revisions $res = $dbw->select( $revQuery['tables'], $revQuery['fields'], [ 'rev_page' => $id ], __METHOD__, - 'FOR UPDATE', + [], $revQuery['joins'] ); diff --git a/includes/parser/MWTidy.php b/includes/parser/MWTidy.php index 330859d4b5..19cf573157 100644 --- a/includes/parser/MWTidy.php +++ b/includes/parser/MWTidy.php @@ -52,27 +52,6 @@ class MWTidy { 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__ . ": tidy driver does not support validate()" ); - } - } - /** * @return bool */ diff --git a/includes/site/MediaWikiPageNameNormalizer.php b/includes/site/MediaWikiPageNameNormalizer.php index 3e073f0882..8a12c4f7ce 100644 --- a/includes/site/MediaWikiPageNameNormalizer.php +++ b/includes/site/MediaWikiPageNameNormalizer.php @@ -54,6 +54,7 @@ class MediaWikiPageNameNormalizer { * 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 will be resolved and the redirect target is returned. + * Only titles of existing pages will be 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. @@ -65,7 +66,9 @@ class MediaWikiPageNameNormalizer { * @param string $pageName * @param string $apiUrl * - * @return string|false + * @return string|false The normalized form of the title, + * or false to indicate an invalid title, a missing page, + * or some other kind of error. * @throws \MWException */ public function normalizePageName( $pageName, $apiUrl ) { diff --git a/includes/site/MediaWikiSite.php b/includes/site/MediaWikiSite.php index 0ff7e8bad4..e1e7ce69cf 100644 --- a/includes/site/MediaWikiSite.php +++ b/includes/site/MediaWikiSite.php @@ -65,6 +65,7 @@ class MediaWikiSite extends Site { * 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 will be resolved and the redirect target is returned. + * Only titles of existing pages will be 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. @@ -79,7 +80,9 @@ class MediaWikiSite extends Site { * * @param string $pageName * - * @return string|false + * @return string|false The normalized form of the title, + * or false to indicate an invalid title, a missing page, + * or some other kind of error. * @throws MWException */ public function normalizePageName( $pageName ) { diff --git a/includes/site/Site.php b/includes/site/Site.php index 55aad77831..f5e3f22ee6 100644 --- a/includes/site/Site.php +++ b/includes/site/Site.php @@ -382,8 +382,10 @@ class Site implements Serializable { } /** - * Returns $pageName without changes. - * Subclasses may override this to apply some kind of normalization. + * Attempt to normalize the page name in some fashion. + * May return false to indicate various kinds of failure. + * + * This implementation returns $pageName without changes. * * @see Site::normalizePageName * diff --git a/includes/specialpage/ChangesListSpecialPage.php b/includes/specialpage/ChangesListSpecialPage.php index eb2cadaee9..2a4acc86ab 100644 --- a/includes/specialpage/ChangesListSpecialPage.php +++ b/includes/specialpage/ChangesListSpecialPage.php @@ -87,9 +87,12 @@ abstract class ChangesListSpecialPage extends SpecialPage { // Same format as filterGroupDefinitions, but for a single group (reviewStatus) // that is registered conditionally. + private $legacyReviewStatusFilterGroupDefinition; + + // Single filter group registered conditionally private $reviewStatusFilterGroupDefinition; - // Single filter registered conditionally + // Single filter group registered conditionally private $hideCategorizationFilterDefinition; /** @@ -301,7 +304,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { ] ], - // reviewStatus (conditional) + // significance (conditional) [ 'name' => 'significance', @@ -457,17 +460,14 @@ abstract class ChangesListSpecialPage extends SpecialPage { ]; - $this->reviewStatusFilterGroupDefinition = [ + $this->legacyReviewStatusFilterGroupDefinition = [ [ - 'name' => 'reviewStatus', + 'name' => 'legacyReviewStatus', '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', @@ -477,27 +477,75 @@ abstract class ChangesListSpecialPage extends SpecialPage { ) { $conds[] = 'rc_patrolled = 0'; }, - 'cssClassSuffix' => 'patrolled', - 'isRowApplicableCallable' => function ( $ctx, $rc ) { - return $rc->getAttribute( 'rc_patrolled' ); - }, + 'isReplacedInStructuredUi' => true, ], [ '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 != 0'; }, - 'cssClassSuffix' => 'unpatrolled', + 'isReplacedInStructuredUi' => true, + ], + ], + ] + ]; + + $this->reviewStatusFilterGroupDefinition = [ + [ + 'name' => 'reviewStatus', + 'title' => 'rcfilters-filtergroup-reviewstatus', + 'class' => ChangesListStringOptionsFilterGroup::class, + 'isFullCoverage' => true, + 'priority' => -5, + 'filters' => [ + [ + 'name' => 'unpatrolled', + 'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label', + 'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description', + 'cssClassSuffix' => 'reviewstatus-unpatrolled', + 'isRowApplicableCallable' => function ( $ctx, $rc ) { + return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED; + }, + ], + [ + 'name' => 'manual', + 'label' => 'rcfilters-filter-reviewstatus-manual-label', + 'description' => 'rcfilters-filter-reviewstatus-manual-description', + 'cssClassSuffix' => 'reviewstatus-manual', 'isRowApplicableCallable' => function ( $ctx, $rc ) { - return !$rc->getAttribute( 'rc_patrolled' ); + return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED; + }, + ], + [ + 'name' => 'auto', + 'label' => 'rcfilters-filter-reviewstatus-auto-label', + 'description' => 'rcfilters-filter-reviewstatus-auto-description', + 'cssClassSuffix' => 'reviewstatus-auto', + 'isRowApplicableCallable' => function ( $ctx, $rc ) { + return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED; }, ], ], + 'default' => ChangesListStringOptionsFilterGroup::NONE, + 'queryCallable' => function ( $specialPageClassName, $ctx, $dbr, + &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected + ) { + if ( $selected === [] ) { + return; + } + $rcPatrolledValues = [ + 'unpatrolled' => RecentChange::PRC_UNPATROLLED, + 'manual' => RecentChange::PRC_PATROLLED, + 'auto' => RecentChange::PRC_AUTOPATROLLED, + ]; + // e.g. rc_patrolled IN (0, 2) + $conds['rc_patrolled'] = array_map( function ( $s ) use ( $rcPatrolledValues ) { + return $rcPatrolledValues[ $s ]; + }, $selected ); + } ] ]; @@ -910,6 +958,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { // 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->legacyReviewStatusFilterGroupDefinition ); $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition ); } @@ -1339,7 +1388,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { } /** - * Replace old options 'hideanons' or 'hideliu' with structured UI equivalent + * Replace old options with their structured UI equivalents * * @param FormOptions $opts * @return bool True if the change was made @@ -1349,21 +1398,40 @@ abstract class ChangesListSpecialPage extends SpecialPage { return false; } + $changed = false; + // At this point 'hideanons' and 'hideliu' cannot be both true, // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case if ( $opts[ 'hideanons' ] ) { $opts->reset( 'hideanons' ); $opts[ 'userExpLevel' ] = 'registered'; - return true; + $changed = true; } if ( $opts[ 'hideliu' ] ) { $opts->reset( 'hideliu' ); $opts[ 'userExpLevel' ] = 'unregistered'; - return true; + $changed = true; } - return false; + if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) { + if ( $opts[ 'hidepatrolled' ] ) { + $opts->reset( 'hidepatrolled' ); + $opts[ 'reviewStatus' ] = 'unpatrolled'; + $changed = true; + } + + if ( $opts[ 'hideunpatrolled' ] ) { + $opts->reset( 'hideunpatrolled' ); + $opts[ 'reviewStatus' ] = implode( + ChangesListStringOptionsFilterGroup::SEPARATOR, + [ 'manual', 'auto' ] + ); + $changed = true; + } + } + + return $changed; } /** diff --git a/includes/specials/SpecialRecentchanges.php b/includes/specials/SpecialRecentchanges.php index cb2f42009c..bfef5e0363 100644 --- a/includes/specials/SpecialRecentchanges.php +++ b/includes/specials/SpecialRecentchanges.php @@ -208,8 +208,12 @@ class SpecialRecentChanges extends ChangesListSpecialPage { $reviewStatus = $this->getFilterGroup( 'reviewStatus' ); if ( $reviewStatus !== null ) { // Conditional on feature being available and rights - $hidePatrolled = $reviewStatus->getFilter( 'hidepatrolled' ); - $hidePatrolled->setDefault( $user->getBoolOption( 'hidepatrolled' ) ); + if ( $user->getBoolOption( 'hidepatrolled' ) ) { + $reviewStatus->setDefault( 'unpatrolled' ); + $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' ); + $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' ); + $legacyHidePatrolled->setDefault( true ); + } } $changeType = $this->getFilterGroup( 'changeType' ); diff --git a/includes/specials/SpecialWatchlist.php b/includes/specials/SpecialWatchlist.php index 3fe6c1e022..dda1dac3af 100644 --- a/includes/specials/SpecialWatchlist.php +++ b/includes/specials/SpecialWatchlist.php @@ -264,8 +264,12 @@ class SpecialWatchlist extends ChangesListSpecialPage { $reviewStatus = $this->getFilterGroup( 'reviewStatus' ); if ( $reviewStatus !== null ) { // Conditional on feature being available and rights - $hidePatrolled = $reviewStatus->getFilter( 'hidepatrolled' ); - $hidePatrolled->setDefault( $user->getBoolOption( 'watchlisthidepatrolled' ) ); + if ( $user->getBoolOption( 'watchlisthidepatrolled' ) ) { + $reviewStatus->setDefault( 'unpatrolled' ); + $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' ); + $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' ); + $legacyHidePatrolled->setDefault( true ); + } } $authorship = $this->getFilterGroup( 'authorship' ); diff --git a/includes/tidy/Balancer.php b/includes/tidy/Balancer.php index c7d9a265aa..6671f49ba7 100644 --- a/includes/tidy/Balancer.php +++ b/includes/tidy/Balancer.php @@ -294,6 +294,9 @@ class BalanceSets { 'span' => true, 'strike' => true, 'strong' => true, 'sub' => true, 'sup' => true, 'textarea' => true, 'tt' => true, 'u' => true, 'var' => true, + // Those defined in tidy.conf + 'video' => true, 'audio' => true, 'bdi' => true, 'data' => true, + 'time' => true, 'mark' => true, ], ]; } diff --git a/includes/tidy/RemexCompatMunger.php b/includes/tidy/RemexCompatMunger.php index c06eea01b1..7cf7bbede6 100644 --- a/includes/tidy/RemexCompatMunger.php +++ b/includes/tidy/RemexCompatMunger.php @@ -61,6 +61,13 @@ class RemexCompatMunger implements TreeHandler { "tt" => true, "u" => true, "var" => true, + // Those defined in tidy.conf + "video" => true, + "audio" => true, + "bdi" => true, + "data" => true, + "time" => true, + "mark" => true, ]; private static $formattingElements = [ diff --git a/includes/tidy/TidyDriverBase.php b/includes/tidy/TidyDriverBase.php index f88b673479..a53360c9d9 100644 --- a/includes/tidy/TidyDriverBase.php +++ b/includes/tidy/TidyDriverBase.php @@ -20,18 +20,6 @@ abstract class TidyDriverBase { 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 * diff --git a/includes/user/User.php b/includes/user/User.php index 3e6b212097..ea395f4acd 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -3084,7 +3084,7 @@ class User implements IDBAccessObject, UserIdentity { * 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 string|array $defaultOverride A default value returned if the option does not exist * @param bool $ignoreHidden Whether to ignore the effects of $wgHiddenPrefs * @return string|array|int|null User's current value for the option * @see getBoolOption() diff --git a/languages/i18n/be-tarask.json b/languages/i18n/be-tarask.json index 391bd6d2c9..c35dc0e93b 100644 --- a/languages/i18n/be-tarask.json +++ b/languages/i18n/be-tarask.json @@ -53,7 +53,7 @@ "tog-watchlisthideminor": "Хаваць дробныя праўкі ў сьпісе назіраньня", "tog-watchlisthideliu": "Хаваць праўкі зарэгістраваных удзельнікаў у сьпісе назіраньня", "tog-watchlistreloadautomatically": "Аўтаматычна перазагружаць сьпіс назіраньня пры зьмене фільтру (патрэбны JavaScript)", - "tog-watchlistunwatchlinks": "Дадаць спасылкі «назіраць/не назіраць» да элемэнтаў сьпісу назіраньня (патрабуецца JavaScript для актывацыі функцыі)", + "tog-watchlistunwatchlinks": "Дадаць спасылкі ({{int:Watchlist-unwatch}}/{{int:Watchlist-unwatch-undo}}) да элемэнтаў сьпісу назіраньня зь зьменамі (патрабуецца JavaScript для актывацыі функцыі)", "tog-watchlisthideanons": "Хаваць праўкі ананімаў у сьпісе назіраньня", "tog-watchlisthidepatrolled": "Хаваць патруляваныя праўкі ў сьпісе назіраньня", "tog-watchlisthidecategorization": "Хаваць катэгарызацыю старонак", @@ -1249,7 +1249,7 @@ "action-move-subpages": "перанос гэтай старонкі і яе падстаронак", "action-move-rootuserpages": "перанос карэнных старонак удзельнікаў", "action-move-categorypages": "перанос старонак катэгорыяў", - "action-movefile": "перайменаваць гэты файл", + "action-movefile": "перайменаваньне гэтага файлу", "action-upload": "загрузку гэтага файла", "action-reupload": "перазапіс гэтага файла", "action-reupload-shared": "перакрыцьцё гэтага файла ў агульным сховішчы", diff --git a/languages/i18n/be.json b/languages/i18n/be.json index db990bbc1a..3e6de409e0 100644 --- a/languages/i18n/be.json +++ b/languages/i18n/be.json @@ -3287,7 +3287,7 @@ "tag-filter-submit": "Фільтр", "tag-list-wrapper": "([[Special:Tags|{{PLURAL:$1|Тэг|Тэгі}}]]: $2)", "tag-mw-contentmodelchange": "змена мадэлі змесціва", - "tag-mw-new-redirect": "Новае перенаправление", + "tag-mw-new-redirect": "Новая перасылка", "tag-mw-removed-redirect": "Выдаленае перанакіраванне", "tag-mw-changed-redirect-target": "Змяненне мэты перенаправления", "tag-mw-blank": "ачыстка", diff --git a/languages/i18n/da.json b/languages/i18n/da.json index b8902d8968..234d3a0cf3 100644 --- a/languages/i18n/da.json +++ b/languages/i18n/da.json @@ -1119,7 +1119,7 @@ "saveusergroups": "Gem {{GENDER:$1|brugergrupper}}", "userrights-groupsmember": "Medlem af:", "userrights-groupsmember-auto": "Implicit medlem af:", - "userrights-groups-help": "Du kan ændre denne brugers gruppemedlemsskaber:\n* Et markeret afkrydsningsfelt betyder at brugeren er medlen af den pågældende gruppe.\n* Et umarkeret felt betyder at brugeren ikke er medlem af gruppen.\n* En * betyder at du ikke kan fravælge gruppen, når den først er tilføjet og omvendt.\n* En # betyder at du kun kan forkorte udløbsperioden for dette gruppemedlemskab; du kan ikke forlænge den.", + "userrights-groups-help": "Du kan ændre denne brugers gruppemedlemsskaber:\n* Et markeret afkrydsningsfelt betyder at brugeren er medlen af den pågældende gruppe.\n* Et umarkeret felt betyder at brugeren ikke er medlem af gruppen.\n* En * betyder at du ikke kan fravælge gruppen, når den først er tilføjet og omvendt.\n* En # betyder at du kun kan forlænge udløbsperioden for dette gruppemedlemskab; du kan ikke forkorte den.", "userrights-reason": "Årsag:", "userrights-no-interwiki": "Du har ikke tilladelse til at redigere brugerrettigheder på andre wikier.", "userrights-nodatabase": "Databasen $1 eksisterer ikke lokalt.", diff --git a/languages/i18n/el.json b/languages/i18n/el.json index 76cd861369..8b081f72e2 100644 --- a/languages/i18n/el.json +++ b/languages/i18n/el.json @@ -93,7 +93,7 @@ "tog-watchlisthideminor": "Απόκρυψη των επεξεργασιών μικρής σημασίας από τη λίστα παρακολούθησης", "tog-watchlisthideliu": "Απόκρυψη επεξεργασιών συνδεδεμένων χρηστών από τη λίστα παρακολούθησης", "tog-watchlistreloadautomatically": "Φορτώσετε εκ νέου η λίστα παρακολούθησής αυτόματα κάθε φορά που ένα φίλτρο έχει αλλάξει (Απαιτείται JavaScript)", - "tog-watchlistunwatchlinks": "Προσθέσετε άμεσους συνδέσμους μη παρακολούθησης/παρακολούθησης για να παρακολουθείτε εγγραφές (απαιτείται JavaScript για την λειτουργική στάθμιση)", + "tog-watchlistunwatchlinks": "Προσθέσετε άμεσους συνδέσμους μη παρακολούθησης/παρακολούθησης ({{int:Watchlist-unwatch}}/{{int:Watchlist-unwatch-undo}}) προς τις παρακολουθούμενες σελίδες με αλλαγές (απαιτείται JavaScript για την λειτουργική στάθμιση)", "tog-watchlisthideanons": "Απόκρυψη επεξεργασιών ανωνύμων χρηστών από τη λίστα παρακολούθησης", "tog-watchlisthidepatrolled": "Απόκρυψη ελεγμένων επεξεργασιών από τη λίστα παρακολούθησης", "tog-watchlisthidecategorization": "Απόκρυψη κατηγοριοποίησης σελίδων", @@ -404,8 +404,10 @@ "cascadeprotected": "Αυτή η σελίδα έχει προστατευθεί από επεξεργασία, επειδή ενσωματώνεται {{PLURAL:$1|στην ακόλουθη σελίδα, που είναι προστατευμένη|στις ακόλουθες σελίδες, που είναι προστατευμένες}} με ενεργοποιημένη τη «διαδοχική» προστασία στο:\n$2", "namespaceprotected": "Δεν έχετε άδεια να επεξεργάζεστε σελίδες στον τομέα '''$1'''.", "customcssprotected": "Δεν έχετε δικαιώματα για να επεξεργαστείτε αυτή τη σελίδα CSS, επειδή περιέχει προσωπικές ρυθμίσεις άλλου χρήστη.", + "customjsonprotected": "Δεν έχετε δικαιώματα για να επεξεργαστείτε αυτή τη σελίδα JSON, επειδή περιέχει προσωπικές ρυθμίσεις άλλου χρήστη.", "customjsprotected": "Δεν έχετε δικαιώματα για να επεξεργαστείτε αυτή τη σελίδα JavaScript, επειδή περιέχει προσωπικές ρυθμίσεις άλλου χρήστη.", "mycustomcssprotected": "Δεν έχετε άδεια για να επεξεργαστείτε αυτήν τη σελίδα CSS.", + "mycustomjsonprotected": "Δεν έχετε άδεια για να επεξεργαστείτε αυτήν τη σελίδα JSON.", "mycustomjsprotected": "Δεν έχετε άδεια για να επεξεργαστείτε αυτήν τη σελίδα JavaScript.", "myprivateinfoprotected": "Δεν έχετε άδεια για να επεξεργαστείτε τα προσωπικά σας στοιχεία.", "mypreferencesprotected": "Δεν έχετε άδεια για να επεξεργαστείτε τις προτιμήσεις σας.", @@ -698,8 +700,10 @@ "blocked-notice-logextract": "Επί του παρόντος, αυτός ο χρήστης έχει υποστεί φραγή. Παρακάτω παρέχεται για αναφορά η πιο πρόσφατη καταχώρηση του αρχείου φραγών.", "clearyourcache": "Σημείωση: μετά την αποθήκευση, ίσως χρειαστεί να παρακάμψετε την προσωρινή μνήμη του προγράμματος περιήγησής σας για να δείτε τις αλλαγές.\n* Firefox / Safari: Κρατήστε πατημένο το Shift κάνοντας ταυτόχρονα κλικ στο κουμπί Ανανέωση ή πιέστε Ctrl-F5 ή Ctrl-R (⌘-R σε Mac)\n* Google Chrome: Πιέστε Ctrl-Shift-R (⌘-Shift-R σε Mac)\n* Internet Explorer: Κρατήστε πατημένο το Ctrl κάνοντας ταυτόχρονα κλικ στο κουμπί Ανανέωση, ή πιέστε Ctrl-F5\n* Opera: Εκκαθαρίστε την προσωρινή μνήμη από το μενού Εργαλεία → Προτιμήσεις", "usercssyoucanpreview": "'''Χρήσιμη συμβουλή:''' Χρησιμοποιήστε το κουμπί \"{{int:showpreview}}\" για να ελέγξτε τα νέα σας CSS πριν τα αποθηκεύσετε.", + "userjsonyoucanpreview": "Συμβουλή: Χρησιμοποιήστε το κουμπί \"{{int:showpreview}}\" για να δοκιμάσετε το νέο σας JSON πριν την αποθήκευση.", "userjsyoucanpreview": "'''Χρήσιμη συμβουλή:''' Χρησιμοποιήστε το κουμπί \"{{int:showpreview}}\" για να ελέγξτε τη νέα σας JS πριν την αποθηκεύσετε.", "usercsspreview": "'''Σας υπενθυμίζουμε ότι κάνετε απλώς έλεγχο/προεπισκόπηση του CSS του χρήστη -δεν το έχετε ακόμα αποθηκεύσει! '''", + "userjsonpreview": "Σας υπενθυμίζουμε ότι κάνετε απλώς έλεγχο/προεπισκόπηση της ρύθμισης JSON σας. Δεν έχει αποθηκευθεί ακόμα!", "userjspreview": "'''Σας υπενθυμίζουμε ότι κάνετε απλώς έλεγχο/προεπισκόπηση του JavaScript του χρήστη -δεν το έχετε ακόμα αποθηκεύσει!'''", "sitecsspreview": "Θυμηθείτε ότι είναι απλώς μια προεπισκόπηση αυτού του CSS.\nΔεν έχει αποθηκευτεί ακόμα!", "sitejspreview": "''' Θυμηθείτε ότι κάνετε μόνο προεπισκόπηση σ'αυτόν τον κώδικα JavaScript.'' '\n'' ' Δεν τον έχετε αποθηκεύσει ακόμη!'' '", @@ -730,7 +734,7 @@ "longpageerror": "'''Σφάλμα: Το κείμενο που καταχωρήσατε έχει μήκος {{PLURAL:$1|ένα kilobyte|$1 kilobytes}}, το οποίο είναι μεγαλύτερο από το μέγιστο {{PLURAL:$2|του ενός kilobyte|των $2 kilobytes}}.'''\nΔεν μπορεί να αποθηκευτεί.", "readonlywarning": "'''Προειδοποίηση: Η βάση δεδομένων έχει κλειδωθεί για συντήρηση, έτσι δεν θα μπορέσετε να αποθηκεύσετε τις επεξεργασίες σας αυτή τη στιγμή.'''\nΜπορείτε αν θέλετε να μεταφέρετε με αντιγραφή-επικόλληση το κείμενό σας σε αρχείο κειμένου και να το αποθηκεύσετε για αργότερα.\n\nΟ διαχειριστής που την κλείδωσε έδωσε την εξής εξήγηση: $1", "protectedpagewarning": "'''Προειδοποίηση: Αυτή η σελίδα έχει κλειδωθεί ώστε μόνο χρήστες με δικαιώματα διαχειριστή μπορούν να την επεξεργαστούν.'''\nΗ πιο πρόσφατη καταχώρηση στο αρχείο καταγραφής παρέχεται παρακάτω για αναφορά:", - "semiprotectedpagewarning": "'''Σημείωση:''' Αυτή η σελίδα έχει κλειδωθεί ώστε μόνο εγγεγραμμένοι χρήστες μπορούν να την επεξεργαστούν.\nΗ πιο πρόσφατη καταχώρηση στο αρχείο καταγραφής παρέχεται παρακάτω για αναφορά:", + "semiprotectedpagewarning": "Σημείωση: Αυτή η σελίδα έχει προστατευθεί ώστε μόνο αυτοεπιβεβαιωμένοι χρήστες μπορούν να την επεξεργαστούν.\nΗ πιο πρόσφατη καταχώρηση στο αρχείο καταγραφής παρέχεται παρακάτω για αναφορά:", "cascadeprotectedwarning": "Προσοχή: Αυτή η σελίδα έχει κλειδωθεί ώστε μόνο χρήστες με [[Special:ListGroupRights|συγκεκριμένα δικαιώματα]] να μπορούν να την επεξεργαστούν, επειδή περιλαμβάνεται {{PLURAL:$1|στην ακόλουθη|στις ακόλουθες}} διαδοχικά (cascaded) {{PLURAL:$1|προστατευμένη σελίδα|προστατευμένες σελίδες}}:", "titleprotectedwarning": "'''Προειδοποίηση: Αυτή η σελίδα έχει κλειδωθεί ώστε χρειάζονται [[Special:ListGroupRights|ειδικά δικαιώματα]] για να δημιουργηθεί.'''\nΗ πιο πρόσφατη καταχώρηση στο αρχείο καταγραφής παρέχεται παρακάτω για αναφορά:", "templatesused": "{{PLURAL:$1|Πρότυπο που χρησιμοποιείται|Πρότυπα που χρησιμοποιούνται}} σε αυτή τη σελίδα:", @@ -1451,8 +1455,10 @@ "rcfilters-exclude-button-on": "Εξαίρεση επιλεγμένων", "rcfilters-view-tags": "Επεξεργασίες με ετικέτες", "rcfilters-view-namespaces-tooltip": "Φιλτράρισμα αποτελεσμάτων κατά ονοματοχώρο", + "rcfilters-view-return-to-default-tooltip": "Επιστροφή στο κύριο μενού φίλτρων", "rcfilters-liveupdates-button": "Ζωντανή ανανέωση", "rcfilters-liveupdates-button-title-on": "Απενεργοποίηση ζωντανής ανανέωσης", + "rcfilters-liveupdates-button-title-off": "Εμφάνιση νέων αλλαγών όπως συμβαίνουν", "rcfilters-watchlist-markseen-button": "Σημειώστε όλες τις αλλαγές ως εξετασμένες", "rcfilters-watchlist-edit-watchlist-button": "Διορθώστε τη λίστα παρακολούθησης", "rcfilters-watchlist-showupdated": "Σελίδες που έχουν υποστεί αλλαγές από την τελευταία φορά που τις επισκεφθήκατε εμφανίζονται με '''έντονους χαρακτήρες'''.", diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 4bdf97e1ba..5cfad4bbc3 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1441,10 +1441,12 @@ "rcfilters-filter-humans-label": "Human (not bot)", "rcfilters-filter-humans-description": "Edits made by human editors.", "rcfilters-filtergroup-reviewstatus": "Review status", - "rcfilters-filter-patrolled-label": "Patrolled", - "rcfilters-filter-patrolled-description": "Edits marked as patrolled.", - "rcfilters-filter-unpatrolled-label": "Unpatrolled", - "rcfilters-filter-unpatrolled-description": "Edits not marked as patrolled.", + "rcfilters-filter-reviewstatus-unpatrolled-description": "Edits not manually or automatically marked as patrolled.", + "rcfilters-filter-reviewstatus-unpatrolled-label": "Unpatrolled", + "rcfilters-filter-reviewstatus-manual-description": "Edits manually marked as patrolled.", + "rcfilters-filter-reviewstatus-manual-label": "Manually patrolled", + "rcfilters-filter-reviewstatus-auto-description": "Edits by advanced users whose work is automatically marked as patrolled.", + "rcfilters-filter-reviewstatus-auto-label": "Autopatrolled", "rcfilters-filtergroup-significance": "Significance", "rcfilters-filter-minor-label": "Minor edits", "rcfilters-filter-minor-description": "Edits the author labeled as minor.", diff --git a/languages/i18n/fr.json b/languages/i18n/fr.json index 60c0251e38..fc59bfc089 100644 --- a/languages/i18n/fr.json +++ b/languages/i18n/fr.json @@ -199,7 +199,7 @@ "tog-watchlisthideminor": "Masquer les modifications mineures dans la liste de suivi", "tog-watchlisthideliu": "Masquer les modifications faites par des utilisateurs inscrits dans la liste de suivi", "tog-watchlistreloadautomatically": "Recharger automatiquement la liste de suivi lorsque les options de filtrage sont modifiées (JavaScript requis)", - "tog-watchlistunwatchlinks": "Ajouter des liens directs pour suivre ou arrêter de suivre les entrées de la liste de suivi (JavaScript est nécessaire pour utiliser la fonctionnalité)", + "tog-watchlistunwatchlinks": "Ajouter des marqueurs directs ({{int:Watchlist-unwatch}}/{{int:Watchlist-unwatch-undo}}) pour les pages suivies avec des changements (JavaScript est nécessaire pour utiliser la fonctionnalité)", "tog-watchlisthideanons": "Masquer les modifications d’utilisateurs anonymes dans la liste de suivi", "tog-watchlisthidepatrolled": "Masquer les modifications relues dans la liste de suivi", "tog-watchlisthidecategorization": "Masquer la catégorisation des pages", diff --git a/languages/i18n/gcr.json b/languages/i18n/gcr.json index 8d0fe74612..d74d7df79e 100644 --- a/languages/i18n/gcr.json +++ b/languages/i18n/gcr.json @@ -38,7 +38,7 @@ "tog-watchlisthideminor": "Maské modifikasyon-yan minò annan lis di swivi", "tog-watchlisthideliu": "Maské modifikasyon-yan ki fè pa dé itilizatò annan lis di swivi", "tog-watchlistreloadautomatically": "Roucharjé otomatikman lis di swivi-a lò lòpsyon di filtraj sa modifyé (JavaScript réki)", - "tog-watchlistunwatchlinks": "Ajouté dé lyen dirèk pou swiv ou arété di swiv antré-ya di lis di swivi (JavaScript sa nésésèr pou itilizé fonksyonalité-a)", + "tog-watchlistunwatchlinks": "Ajouté dé markèr dirèk ({{int:Watchlist-unwatch}}/{{int:Watchlist-unwatch-undo}}) pou paj-ya swivi ké dé chanjman (JavaScript sa nésésèr pou itilizé fonksyonalité-a)", "tog-watchlisthideanons": "Maské modifikasyon-yan di itilizatò anonim annan lis di swivi", "tog-watchlisthidepatrolled": "Maské modifikasyon-yan ki rouli annan lis di swivi", "tog-watchlisthidecategorization": "Maské katégorizasyon dé paj", @@ -436,7 +436,19 @@ "noname": "Zòt pa sézi roun non d'itilizatò valid.", "loginsuccesstitle": "Konèkté", "loginsuccess": "Zòt atchwèl konèkté à {{SITENAME}} antan ki « $1 ».", + "nosuchuser": "Itilizatò « $1 » pa ka ègzisté.\nNon d'itilizatò-ya sa sansib à lakas.\nVérifyé lòrtograf, oben [[Special:CreateAccount|kréyé roun kont nòv]].", + "nosuchusershort": "I pa gen kontribitò ké non-an « $1 ».\nSouplé, vérifyé lòrtograf.", + "nouserspecified": "Zòt divèt sézi roun non d'itilizatò.", + "login-userblocked": "{{GENDER:$1|Sa itilizatò|Sa itilizatris}} bloké. Konèksyon-an pa otorizé.", + "wrongpassword": "Non d'itilizatò oben mo di pas enkorèk.\nSouplé, éséyé òkò.", + "wrongpasswordempty": "Zòt pa antré pyès mo di pas.\nSouplé, éséyé òkò.", + "passwordtooshort": "Zòt mo di pas divèt kontni omwen $1 karaktèr{{PLURAL:$1|}}.", + "passwordtoolong": "Mo di pas pa pouvé dépasé $1 karaktèr{{PLURAL:$1|}}.", + "passwordtoopopular": "Mo di pas ki tròp kouran pa pouvé fika itilizé. Souplé, chwézi roun mo di pas pli difisil à douviné.", + "password-name-match": "Zòt mo di pas divèt fika diféran di zòt non d'itilizatò.", + "password-login-forbidden": "Itilizasyon-an di sa non d'itilizatò oben di sa mo di pas té entèrdit.", "mailmypassword": "Réyinisyalizé mo di pas", + "passwordremindertitle": "Nouvèl mo di pas tanporèr pou {{SITENAME}}", "noemail": "Pyès adrès di kouryé té anréjistré pou itilizat{{GENDER:$1|ò|ris}}-a « $1 ».", "noemailcreate": "Zòt divèt fourni roun adrès di kouryé valid", "accountcreated": "Kont kréyé", diff --git a/languages/i18n/he.json b/languages/i18n/he.json index ac19bbdf90..f3ee776a2d 100644 --- a/languages/i18n/he.json +++ b/languages/i18n/he.json @@ -731,7 +731,7 @@ "permissionserrorstext": "אין באפשרותך לבצע פעולה זו, {{PLURAL:$1|מהסיבה הבאה|מהסיבות הבאות}}:", "permissionserrorstext-withaction": "אין באפשרותך $2, {{PLURAL:$1|מהסיבה הבאה|מהסיבות הבאות}}:", "contentmodelediterror": "לא ניתן לערוך את הגרסה הזאת כי מודל התוכן שלה הוא $1, השונה ממודל התוכן הנוכחי של הדף, $2.", - "recreate-moveddeleted-warn": "'''אזהרה: הנכם יוצרים דף חדש שנמחק בעבר.'''\n\nכדאי לשקול אם יהיה זה נכון להמשיך לערוך את הדף.\nיומני המחיקות וההעברות של הדף מוצגים להלן:", + "recreate-moveddeleted-warn": "אזהרה: דף בשם זה נמחק בעבר.\n\nכדאי לשקול אם יהיה זה נכון להמשיך לערוך את הדף.\nיומני המחיקות וההעברות של הדף מוצגים להלן:", "moveddeleted-notice": "דף זה נמחק.\nיומני המחיקות, ההגנות וההעברות של הדף מוצגים להלן לעיון.", "moveddeleted-notice-recent": "מצטערים, הדף הזה נמחק לאחרונה (ב־24 השעות האחרונות).\nיומני המחיקות, ההגנות וההעברות של הדף מוצגים להלן לעיון.", "log-fulllog": "הצגת היומן המלא", diff --git a/languages/i18n/io.json b/languages/i18n/io.json index 6bc2fe5429..c99651a578 100644 --- a/languages/i18n/io.json +++ b/languages/i18n/io.json @@ -240,8 +240,8 @@ "mainpage": "Chefpagino", "mainpage-description": "Chefpagino", "policy-url": "Project:Sistemo di agado", - "portal": "Komuneso-portalo", - "portal-url": "Project:Komuneso-portalo", + "portal": "Portalo di la komunitato", + "portal-url": "Project:Portalo di la komunitato", "privacy": "Sistemo di agado pri privateso", "privacypage": "Project:Sistemo di agado pri privateso", "badaccess": "Eroro permisal", @@ -616,7 +616,7 @@ "anontalkpagetext": "----\nYen la diskuto-pagino por anonima uzero, qua ankore ne kreabas konto, o se kreis ne uzas ol.\nDo, ni mustas uzar la IP-adreso por identifikar ilu/elu.\nTala IP-adreso povas uzesar da multa uzeri.\nSe vu esas anonima uzero e kreas ke nerelevanta komenti sendesis a vu, voluntez [[Special:CreateAccount|krear konto]], o [[Special:UserLogin|facar 'log in']] por preventar futura konfundo kun altra anonima uzeri.", "noarticletext": "Til nun ne existas texto en ica pagino.\nVu povas [[Special:Search/{{PAGENAME}}|serchar ica titulo]] en altra pagini, [{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} serchar en la relata registri], o [{{fullurl:{{FULLPAGENAME}}|action=edit}} redaktar ica pagino].", "noarticletext-nopermission": "Til nun ne existas texto en ica pagino.\nVu povas [[Special:Search/{{PAGENAME}}|serchar ica titulo]] en altra pagini, [{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} serchar en la relata registri], o [{{fullurl:{{FULLPAGENAME}}|action=edit}} redaktar ica pagino], tamen vu ne havas permiso por krear ica pagino.", - "userpage-userdoesnotexist": "Uzeronomo \"$1\" no registragesis.\nVoluntez konfirmez se vu volas krear/redaktar ica pagino.", + "userpage-userdoesnotexist": "Uzeronomo \"$1\" ne registragesis.\nVoluntez konfirmar se vu volas krear/redaktar ica pagino.", "userpage-userdoesnotexist-view": "Uzeronomo \"$1\" no registragesis.", "clearyourcache": "Atencez: Pos registragar, vu probable mustas renovigar la tempala-magazino di vua navigilo por vidar la chanji.\n* Firefox / Safari:Tenez Shift kliktante Reload, o presez sive Ctrl-F5 sive Ctrl-R (⌘-R ye Mac);\n* Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R en komputeri Mac)\n* Internet Explorer: Tenez Ctrl kliktante Refresh, o presez Ctrl-F5\n* Opera: Irez a Menu → Settings (Opera → Preferences ye komputeri Mac) e pose a Privacy & security → Clear browsing data → Cached images and files.", "usercsspreview": "'''Memorez ke vu nur previdas vua uzero-CSS.'''\n'''Ol ne registragesis ankore!'''", @@ -958,7 +958,7 @@ "recentchangeslinked-feed": "Relatanta chanji", "recentchangeslinked-toolbox": "Relatanta chanji", "recentchangeslinked-title": "Chanji pri \"$1\"", - "recentchangeslinked-summary": "Skribez la nomo di ula pagino por vidar la modifiki en pagini ligita ad ol. (Por vidar la membri di ula kategoriio, skribez Kategorio:Nomo di la kategorio). Chanji en la pagini qui esas en [[Special:Watchlist|vua surveryo-listo]] aparas en dika literi.", + "recentchangeslinked-summary": "Skribez la nomo di ula pagino por vidar la modifikuri en pagini ligita ad ol. (Por vidar la membri di ula kategoriio, skribez Kategorio:Nomo di la kategorio). Chanji en la pagini qui esas en [[Special:Watchlist|vua surveryo-listo]] aparos en dika literi.", "recentchangeslinked-page": "Nomo di la pagino:", "recentchangeslinked-to": "Montrez chanji a pagini ligita a la specigita pagino vice", "autochange-username": "Automatala chanjo di MediaWiki", @@ -1180,7 +1180,7 @@ "enotif_impersonal_salutation": "Uzero di {{SITENAME}}", "enotif_anon_editor": "anonima uzero $1", "deletepage": "Efacar pagino", - "confirm": "Konfirmar", + "confirm": "Konfirmez", "excontent": "La kontenajo esis: '$1'", "excontentauthor": "la kontenajo esis: \"$1\", e l'unika redaktero esis \"[[Special:Contributions/$2|$2]]\" ([[User talk:$2|talk]])", "exbeforeblank": "La kontenajo ante efaco esis: '$1'", @@ -1297,7 +1297,7 @@ "whatlinkshere-filters": "Filtrili", "block": "Blokusar uzero", "blockip": "Blokusado di IP-adresi", - "blockiptext": "Uzez la formulario adinfre por blokusar aceso de specifika adreso IP o de specifika uzeronomo.\nTo mustos facesor NUR POR PREVENTAR VANDALISMO, segun la [[{{MediaWiki:Policy-url}}|politiko de ica Wiki]].\nInformez adinfre la specifika motivi (example, mencionez specifika pagini qui subisis vandalismo dal IP/uzero).\nVu povas blokuzar serio di adresi IP per l'uzo dil sintaxo [https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing CIDR]; la maxim longa serio permisata esas /$1 por IPv4 e /$2 por IPv6.", + "blockiptext": "Uzez la formulario adinfre por blokusar aceso de specifika adreso IP o de specifika uzeronomo.\nFacez to NUR POR PREVENTAR VANDALISMO, e segun la [[{{MediaWiki:Policy-url}}|politiko de ica Wiki]].\nInformez adinfre la specifika motivi (example, mencionez specifika pagini qui subisis vandalismo dal IP/uzero).\nVu povas blokusar serio di adresi IP per l'uzo dil sintaxo [https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing CIDR]; la maxim longa serio permisata esas /$1 por IPv4 e /$2 por IPv6.", "ipaddressorusername": "IP-adreso od uzantonomo:", "ipbexpiry": "Expiro:", "ipbreason": "Motivo:", @@ -1331,7 +1331,7 @@ "reblock-logentry": "modifikis la tempo di blokuso [[$1]] por durado di $2 $3", "unblocklogentry": "desblokusis \"$1\"", "block-log-flags-nocreate": "ne povas krear konto", - "block-log-flags-noemail": "e-posto blokuzita", + "block-log-flags-noemail": "e-posto blokusita", "ipb_expiry_invalid": "Nevalida expiro-tempo.", "ipb-otherblocks-header": "Altra {{PLURAL:$1|blokuso|blokusi}}", "ip_range_invalid": "Nevalida IP-rango.", @@ -1549,9 +1549,11 @@ "exif-gpsspeed-m": "Milii per horo", "namespacesall": "omna", "monthsall": "omna", + "confirmemail": "Konfirmez adreso di e-posto", "confirmemail_needlogin": "Vu mustas $1 pro konfirmar vua adreso di e-posto.", "scarytranscludetoolong": "[URL es tro longa]", "deletedwhileediting": "'''Averto''': Ta pagino efacesis pos ke vu redakteskis!", + "confirmrecreate-noreason": "Uzero [[User:$1|$1]] ([[User talk:$1|mesaji]]) {{GENDER:$1|efacis}} la pagino quon vu komencis redaktar. Voluntez konfirmar se vu fakte deziras rikrear ica pagino.", "recreate": "Rikrear", "confirm_purge_button": "O.K.", "imgmultipageprev": "← antea pagino", diff --git a/languages/i18n/it.json b/languages/i18n/it.json index 0dbf484fd8..de87cad107 100644 --- a/languages/i18n/it.json +++ b/languages/i18n/it.json @@ -152,7 +152,7 @@ "tog-watchlisthideminor": "Nascondi le modifiche minori negli osservati speciali", "tog-watchlisthideliu": "Nascondi le modifiche degli utenti registrati negli osservati speciali", "tog-watchlistreloadautomatically": "Ricarica automaticamente l'elenco degli osservati speciali ogni volta che si modifica un filtro (richiede JavaScript)", - "tog-watchlistunwatchlinks": "Aggiungi collegamenti diretti per seguire/non seguire gli elementi negli osservati speciali (richiede JavaScript per utilizzare questa funzionalità)", + "tog-watchlistunwatchlinks": "Aggiungi marcatori diretti ({{int:Watchlist-unwatch}}/{{int:Watchlist-unwatch-undo}}) per seguire/non seguire le modifiche alle pagine (richiede JavaScript per utilizzare questa funzionalità)", "tog-watchlisthideanons": "Nascondi le modifiche degli utenti anonimi negli osservati speciali", "tog-watchlisthidepatrolled": "Nascondi le modifiche verificate negli osservati speciali", "tog-watchlisthidecategorization": "Nascondi la categorizzazione delle pagine", diff --git a/languages/i18n/nl.json b/languages/i18n/nl.json index 799149ff3e..36fdc542a4 100644 --- a/languages/i18n/nl.json +++ b/languages/i18n/nl.json @@ -126,7 +126,7 @@ "tog-watchlisthideminor": "Kleine bewerkingen op mijn volglijst verbergen", "tog-watchlisthideliu": "Bewerkingen van aangemelde gebruikers op mijn volglijst verbergen", "tog-watchlistreloadautomatically": "De volglijst automatisch herladen wanneer er een filter wordt veranderd (JavaScript vereist)", - "tog-watchlistunwatchlinks": "Volgen/niet volgen-links toevoegen aan regels in mijn volglijst (JavaScript vereist voor schakelfunctionaliteit)", + "tog-watchlistunwatchlinks": "Volgen/niet volgen-markers ({{int:Watchlist-unwatch}}/{{int:Watchlist-unwatch-undo}}) toevoegen aan pagina's met wijzigingen in mijn volglijst (JavaScript vereist voor schakelfunctionaliteit)", "tog-watchlisthideanons": "Bewerkingen van anonieme gebruikers op mijn volglijst verbergen", "tog-watchlisthidepatrolled": "Gemarkeerde wijzigingen op mijn volglijst verbergen", "tog-watchlisthidecategorization": "Categorisatie van pagina's op mijn volglijst verbergen", diff --git a/languages/i18n/oc.json b/languages/i18n/oc.json index fa7cfff733..60d85dce7e 100644 --- a/languages/i18n/oc.json +++ b/languages/i18n/oc.json @@ -345,6 +345,7 @@ "title-invalid-relative": "Lo títol conten un camin relatiu. Los títols relatius (./, ../) son pas valids perque los navigadors web pòdon pas sovent i arribar.", "title-invalid-magic-tilde": "Lo títol de la pagina sollicitada conten una sequéncia de tildas pas valida (~~~).", "title-invalid-too-long": "Lo títol de la pagina sollicitada es tròp long. A pas d’excedir $ 1 {{PLURALA:$1|byte|bytes}} en codificacion UTF-8.", + "title-invalid-leading-colon": "Lo títol de la pagina sollicitada conten dos ponches a la debuta.", "perfcached": "Las donadas seguendas son en cache e benlèu, son pas a jorn. Un maximum de {{PLURAL:$1|un resultat|$1 resultats}} es disponible dins lo cache.", "perfcachedts": "Las donadas seguendas son en cache e benlèu, son pas a jorn. Un maximum de {{PLURAL:$1|un resultat|$1 resultats}} es disponible dins lo cache.", "querypage-no-updates": "Las mesas a jorn per aquesta pagina son actualamnt desactivadas. Las donadas çaijós son pas mesas a jorn.", @@ -438,6 +439,7 @@ "createacct-benefit-body2": "{{PLURAL:$1|pagina|paginas}}", "createacct-benefit-body3": "{{PLURAL:$1|contributor recent|contributors recents}}", "badretype": "Los senhals qu'avètz picats son pas identics.", + "usernameinprogress": "La creacion d'un compte per aquel usatgièr es ja en cors. Esperatz.", "userexists": "Lo nom d'utilizaire qu'avètz picat ja es utilizat.\nCausissètz-ne un autre.", "loginerror": "Error d'identificacion", "createacct-error": "Error al moment de la creacion del compte", @@ -482,6 +484,7 @@ "createaccount-text": "Qualqu'un a creat un compte per vòstra adreça de corrièr electronic sus {{SITENAME}} ($4) intitolat « $2 », amb per senhal « $3 ». Deuriaz dobrir una sessilha e cambiar, tre ara, aqueste senhal.\n\nIgnoratz aqueste messatge se aqueste compte es estat creat per error.", "login-throttled": "Avètz ensajat un tròp grand nombre de connexions darrièrament.\nEsperatz $1 abans d’ensajar tornarmai.", "login-abort-generic": "Vòstra temptativa de connexion a fracassat - Anullat", + "login-migrated-generic": "S'a migrat lo vòstre compte, e lo vòstre nom d'usatgièr existís pas mai en aquel wiki.", "loginlanguagelabel": "Lenga: $1", "suspicious-userlogout": "Vòstra demanda de desconnexion es estada refusada perque sembla qu’es estada mandada per un navigador copat o la mesa en cache d’un proxy.", "createacct-another-realname-tip": "Lo nom vertadièr es opcional.\nSe decidissètz de lo provesir, serà utilizat per atribuir a l’utilizaire sos trabalhs.", @@ -516,9 +519,11 @@ "botpasswords-label-delete": "Suprimir", "botpasswords-label-resetpassword": "Reïnicializar lo senhal", "botpasswords-label-grants": "Dreits aplicables :", + "botpasswords-help-grants": "Lo vòstre compte d'usatgièr dispausa ja de las autorizacions d'accès. Lo fach d'abilitar una autorizacion aicí dona pas cap d'accès que lo vòstre compte d'usatgièr aguès pas abans. Vejatz la [[Special:ListGrants|lista d'autorizacions]] per mai informacion.", "botpasswords-label-grants-column": "Acordat", "botpasswords-bad-appid": "Lo nom del robòt «$1» es pas valid.", "botpasswords-insert-failed": "Fracàs de l’apondon del nom de robòt « $1 ». Es ja estat apondut ?", + "botpasswords-update-failed": "Impossible d'actualizar lo nom del bot \"$1\". Foguèt suprimit?", "botpasswords-created-title": "Senhal de robòts creat", "botpasswords-created-body": "Lo senhal pel robòt « $1 » de l'{{GENDER:$2|utilizaire|utilizaira}} « $2 » es estat creat.", "botpasswords-updated-title": "Senhal de robòts mes a jorn", @@ -526,6 +531,9 @@ "botpasswords-deleted-title": "Senhal de robòts suprimit", "botpasswords-deleted-body": "Lo senhal pel robòt « $1 » de l'{{GENDER:$2|utilizaire|utilizaira}} « $2 » es suprimit.", "botpasswords-no-provider": "BotPasswordsSessionProvider es pas disponible.", + "botpasswords-restriction-failed": "Las restriccions de mot de passa de bots empachan aquela connexion.", + "botpasswords-invalid-name": "Lo nom d'usatgièr especificat conten pas de separador de mot de passa de robòt (\"$1\").", + "botpasswords-not-exist": "L'usatgièr «$1» a pas de mot de passa de robòt nomenat «$2».", "resetpass_forbidden": "Los senhals pòdon pas èsser cambiats", "resetpass_forbidden-reason": "Los senhaus pòdon pas èsser cambiats : $1", "resetpass-no-info": "Vos cal èsser connectat per aver accès a aquesta pagina.", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 6e54765e83..374dd9072a 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1638,10 +1638,12 @@ "rcfilters-filter-humans-label": "Label for the filter for showing edits made by human editors.", "rcfilters-filter-humans-description": "Description for the filter for showing edits made by human editors.", "rcfilters-filtergroup-reviewstatus": "Title for the filter group about review status (in core this is whether it's been patrolled)", - "rcfilters-filter-patrolled-label": "Label for the filter for showing patrolled edits", - "rcfilters-filter-patrolled-description": "Label for the filter showing patrolled edits", - "rcfilters-filter-unpatrolled-label": "Label for the filter for showing unpatrolled edits", - "rcfilters-filter-unpatrolled-description": "Description for the filter for showing unpatrolled edits", + "rcfilters-filter-reviewstatus-manual-description": "Description for the filter showing manually patrolled edits", + "rcfilters-filter-reviewstatus-manual-label": "Label for the filter showing manually patrolled edits", + "rcfilters-filter-reviewstatus-auto-description": "Description for the filter showing automatically patrolled edits", + "rcfilters-filter-reviewstatus-auto-label": "Label for the filter showing automatically patrolled edits", + "rcfilters-filter-reviewstatus-unpatrolled-description": "Description for the filter for showing unpatrolled edits", + "rcfilters-filter-reviewstatus-unpatrolled-label": "Label for the filter for showing unpatrolled edits", "rcfilters-filtergroup-significance": "Title for the filter group for edit significance.\n{{Identical|Significance}}", "rcfilters-filter-minor-label": "Label for the filter for showing edits marked as minor.", "rcfilters-filter-minor-description": "Description for the filter for showing edits marked as minor.", diff --git a/languages/i18n/sl.json b/languages/i18n/sl.json index 73660fae1c..79c53cb64f 100644 --- a/languages/i18n/sl.json +++ b/languages/i18n/sl.json @@ -52,7 +52,7 @@ "tog-watchlisthideminor": "Na spisku nadzorov skrij manjša urejanja", "tog-watchlisthideliu": "Na spisku nadzorov skrij urejanja prijavljenih uporabnikov", "tog-watchlistreloadautomatically": "Samodejno ponovno naloži spisek nadzorov ob spremembi filtra (zahteva JavaScript)", - "tog-watchlistunwatchlinks": "Dodaj neposredne povezave za dodajanje/odstranjevanje strani s spiska nadzorov (za funkcionalnost preklopa je zahtevan JavaScript)", + "tog-watchlistunwatchlinks": "Dodaj oznake za dodajanje/odstranjevanje ({{int:Watchlist-unwatch}}/{{int:Watchlist-unwatch-undo}}) spremenjenih strani s spiska nadzorov (za funkcionalnost preklopa je zahtevan JavaScript)", "tog-watchlisthideanons": "Na spisku nadzorov skrij urejanja anonimnih uporabnikov", "tog-watchlisthidepatrolled": "Na spisku nadzorov skrij pregledana urejanja", "tog-watchlisthidecategorization": "Skrij kategorizacijo strani", diff --git a/maintenance/Maintenance.php b/maintenance/Maintenance.php index 9685177b96..13fee9c6ca 100644 --- a/maintenance/Maintenance.php +++ b/maintenance/Maintenance.php @@ -35,6 +35,10 @@ use Wikimedia\Rdbms\DBReplicationWaitError; // Define this so scripts can easily find doMaintenance.php define( 'RUN_MAINTENANCE_IF_MAIN', __DIR__ . '/doMaintenance.php' ); + +/** + * @deprecated since 1.31 + */ define( 'DO_MAINTENANCE', RUN_MAINTENANCE_IF_MAIN ); // original name, harmless $maintClass = false; @@ -1211,6 +1215,12 @@ abstract class Maintenance { } define( 'MW_DB', $bits[0] ); define( 'MW_PREFIX', $bits[1] ); + } elseif ( isset( $this->mOptions['server'] ) ) { + // Provide the option for site admins to detect and configure + // multiple wikis based on server names. This offers --server + // as alternative to --wiki. + // See https://www.mediawiki.org/wiki/Manual:Wiki_family + $_SERVER['SERVER_NAME'] = $this->mOptions['server']; } if ( !is_readable( $settingsFile ) ) { @@ -1218,9 +1228,6 @@ abstract class Maintenance { "must exist and be readable in the source directory.\n" . "Use --conf to specify it." ); } - if ( isset( $this->mOptions['server'] ) ) { - $_SERVER['SERVER_NAME'] = $this->mOptions['server']; - } $wgCommandLineMode = true; return $settingsFile; diff --git a/maintenance/resources/update-ooui.sh b/maintenance/resources/update-ooui.sh index 673d5070c8..fc8a0e9d47 100755 --- a/maintenance/resources/update-ooui.sh +++ b/maintenance/resources/update-ooui.sh @@ -11,7 +11,7 @@ fi REPO_DIR=$(cd "$(dirname $0)/../.."; pwd) # Root dir of the git repo working tree TARGET_DIR="resources/lib/oojs-ui" # Destination relative to the root of the repo -NPM_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'update-oojs-ui') # e.g. /tmp/update-oojs-ui.rI0I5Vir +NPM_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'update-ooui') # e.g. /tmp/update-ooui.rI0I5Vir # Prepare working tree cd "$REPO_DIR" @@ -72,13 +72,6 @@ cp ./node_modules/oojs-ui/dist/themes/wikimediaui/images/textures/*.{gif,svg} "$ cp ./node_modules/oojs-ui/src/themes/wikimediaui/*.json "$REPO_DIR/$TARGET_DIR/themes/wikimediaui" # Apex theme icons, indicators, and textures -mkdir -p "$REPO_DIR/$TARGET_DIR/themes/apex/images/icons" -cp ./node_modules/oojs-ui/dist/themes/apex/images/icons/*.{svg,png} "$REPO_DIR/$TARGET_DIR/themes/apex/images/icons" -mkdir -p "$REPO_DIR/$TARGET_DIR/themes/apex/images/indicators" -cp ./node_modules/oojs-ui/dist/themes/apex/images/indicators/*.{svg,png} "$REPO_DIR/$TARGET_DIR/themes/apex/images/indicators" -mkdir -p "$REPO_DIR/$TARGET_DIR/themes/apex/images/textures" -cp ./node_modules/oojs-ui/dist/themes/apex/images/textures/*.{gif,svg} "$REPO_DIR/$TARGET_DIR/themes/apex/images/textures" - cp ./node_modules/oojs-ui/src/themes/apex/*.json "$REPO_DIR/$TARGET_DIR/themes/apex" # WikimediaUI LESS variables for sharing diff --git a/mw-config/config.css b/mw-config/config.css index ea5835d487..2468c71563 100644 --- a/mw-config/config.css +++ b/mw-config/config.css @@ -174,6 +174,11 @@ min-width: 20em; } +/* Hide empty live-log textarea */ +#config-live-log textarea:empty { + display: none; +} + /* tooltip styles */ .config-help-field-hint { display: none; diff --git a/resources/src/jquery/jquery.makeCollapsible.css b/resources/src/jquery/jquery.makeCollapsible.css index 2e5efbac8b..693cd7f9b3 100644 --- a/resources/src/jquery/jquery.makeCollapsible.css +++ b/resources/src/jquery/jquery.makeCollapsible.css @@ -6,6 +6,12 @@ -ms-user-select: none; user-select: none; } +.mw-collapsible-toggle:before { + content: '['; +} +.mw-collapsible-toggle:after { + content: ']'; +} /* Align the toggle based on the direction of the content language */ /* @noflip */ .mw-content-ltr .mw-collapsible-toggle, diff --git a/resources/src/jquery/jquery.makeCollapsible.js b/resources/src/jquery/jquery.makeCollapsible.js index aa76d6dfbb..7826baba0f 100644 --- a/resources/src/jquery/jquery.makeCollapsible.js +++ b/resources/src/jquery/jquery.makeCollapsible.js @@ -8,7 +8,6 @@ * @class jQuery.plugin.makeCollapsible */ ( function ( $, mw ) { - /** * Handler for a click on a collapsible toggler. * @@ -270,8 +269,6 @@ role: 'button', tabindex: 0 } ) - .prepend( '[' ) - .append( ']' ) .on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler ); }; diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less index a9c2096e67..928b4831c7 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less @@ -1,5 +1,6 @@ @import 'mediawiki.mixins'; @import 'mediawiki.ui/variables'; +@import 'mw.rcfilters.variables'; .mw-rcfilters-ui-filterMenuHeaderWidget { &-title { @@ -21,18 +22,22 @@ &-invert, &-highlight { - width: 1em; + min-width: 1em; vertical-align: middle; // Using the same padding that the filter item // uses, so the button is aligned with the highlight // buttons for the filters - padding-right: 0.5em; + padding-right: 12 / @font-size-system-ui / @font-size-vector; } &-back { width: 1em; vertical-align: middle; - padding-left: 0.5em; + + .mw-rcfilters-ui-filterMenuHeaderWidget-backButton:first-child { + // Overwrite `.oo-ui-buttonElement-frameless.oo-ui-iconElement:first-child` + margin-left: 0; + } } &-title { diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuOptionWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuOptionWidget.less index 0f858e6cd4..07e43c000a 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuOptionWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuOptionWidget.less @@ -1,7 +1,12 @@ @import 'mediawiki.mixins'; @import 'mediawiki.ui/variables'; +@import 'mw.rcfilters.variables'; .mw-rcfilters-ui-filterMenuOptionWidget { + .mw-rcfilters-ui-filterMenuSectionOptionWidget ~ & { + padding-left: 12 / @font-size-system-ui / @font-size-vector; + } + &.oo-ui-flaggedElement-muted { &:not( .oo-ui-optionWidget-selected ) { // Namespaces are muted 'the other way around' when they @@ -24,4 +29,12 @@ } } + // Override OOUI's pretty specific + // `.oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header` + // selector + .mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox > .oo-ui-fieldLayout > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header { + padding-top: 0; + padding-bottom: 0; + padding-left: 12 / @font-size-system-ui / @font-size-vector; + } } diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuSectionOptionWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuSectionOptionWidget.less index 3e32c83175..50073ff9b3 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuSectionOptionWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuSectionOptionWidget.less @@ -1,27 +1,25 @@ @import 'mediawiki.mixins'; @import 'mediawiki.ui/variables'; +@import 'mw.rcfilters.variables'; .mw-rcfilters-ui-filterMenuSectionOptionWidget { - background: @colorGray14; - padding-bottom: 0.7em; - - &-header { - padding: 0 0.75em; - // Use a high specificity to override OOUI - .oo-ui-optionWidget.oo-ui-labelElement &-title.oo-ui-labelElement-label { - color: @colorGray5; - .box-sizing( border-box ); - display: inline-block; - } + background-color: @colorGray14; + padding-bottom: 8 / @font-size-system-ui / @font-size-vector; + padding-left: 12 / @font-size-system-ui / @font-size-vector; + padding-right: 12 / @font-size-system-ui / @font-size-vector; + + &-header-title.oo-ui-labelElement-label { + color: @colorGray5; + display: inline-block; } &-whatsThisButton { margin-left: 1.5em; &.oo-ui-buttonElement > .oo-ui-buttonElement-button { - font-weight: normal; border: 0; // Override OOUI `border` needed for frameless keyboard focus padding: 0; + font-weight: normal; &:focus { .box-shadow( none ); @@ -33,8 +31,8 @@ padding: 1em; &-header { - font-weight: bold; margin-bottom: 1em; + font-weight: bold; } &-link { @@ -47,9 +45,7 @@ } } - &-active { - .mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title { - font-weight: bold; - } + &-active .mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title { + font-weight: bold; } } diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less index 21a169ced6..72b40feaa8 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less @@ -12,7 +12,7 @@ } &-view-namespaces { - border-top: 5px solid @colorGray12; + border-top: 4px solid @colorGray12; &:first-child, &.mw-rcfilters-ui-itemMenuOptionWidget-identifier-subject + &.mw-rcfilters-ui-itemMenuOptionWidget-identifier-talk { @@ -25,7 +25,8 @@ } .mw-rcfilters-ui-table { - padding-top: 0.5em; + padding-top: 6 / @font-size-system-ui / @font-size-vector; + padding-bottom: 6 / @font-size-system-ui / @font-size-vector; } &.oo-ui-optionWidget-selected { @@ -45,6 +46,10 @@ } &-itemCheckbox { + .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header { + padding-left: 12 / @font-size-system-ui / @font-size-vector; + } + .oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline { // Override margin-top and -bottom rules from FieldLayout margin: 0 !important; /* stylelint-disable-line declaration-no-important */ diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.MenuSelectWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.MenuSelectWidget.less index 0906d6811b..198c8209e9 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.MenuSelectWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.MenuSelectWidget.less @@ -1,5 +1,6 @@ @import 'mediawiki.mixins'; @import 'mediawiki.ui/variables'; +@import 'mw.rcfilters.variables'; .mw-rcfilters-ui-menuSelectWidget { z-index: auto; @@ -10,8 +11,8 @@ } &-noresults { - padding: 0.5em; color: @colorGray5; + padding: 12 / @font-size-system-ui / @font-size-vector; } &-body { @@ -19,9 +20,9 @@ } &-footer { - padding: 0.5em; background-color: @colorGray15; border-top: 1px solid @colorGray12; + padding: 12 / @font-size-system-ui / @font-size-vector; & + & { border-top: 0; diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.variables.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.variables.less index 5f97e1e3f6..987f525518 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.variables.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.variables.less @@ -1,3 +1,8 @@ +// “External” variables +@font-size-system-ui: 16; // Assumed browser default of `16px` +@font-size-vector: 0.875em; // equals `14px` at browser default of `16px` + +// RCFilters variables @background-color-base: #fff; @background-color-primary: #eaf3ff; @color-base--inverted: #fff; diff --git a/resources/src/mediawiki.ui/components/icons.less b/resources/src/mediawiki.ui/components/icons.less index d185b24ab2..461de2f0ff 100644 --- a/resources/src/mediawiki.ui/components/icons.less +++ b/resources/src/mediawiki.ui/components/icons.less @@ -30,6 +30,11 @@ min-height: @iconSize; min-width: @iconSize; + // If an inline element has been marked as a mw-ui-icon element it must be inline-block + span& { + display: inline-block; + } + // Standalone icons // // Markup: diff --git a/resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js b/resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js index 7d49a09b5e..76d4bfbac6 100644 --- a/resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js +++ b/resources/src/mediawiki.widgets/mw.widgets.TitleOptionWidget.js @@ -51,6 +51,9 @@ // Parent constructor mw.widgets.TitleOptionWidget.parent.call( this, config ); + // Remove check icon + this.checkIcon.$element.remove(); + // Initialization this.$label.attr( 'href', config.url ); this.$element.addClass( 'mw-widget-titleOptionWidget' ); diff --git a/tests/parser/ParserTestRunner.php b/tests/parser/ParserTestRunner.php index 28335ecdde..844a43f34a 100644 --- a/tests/parser/ParserTestRunner.php +++ b/tests/parser/ParserTestRunner.php @@ -615,9 +615,13 @@ class ParserTestRunner { return false; } );// hooks::register + // Reset the service in case any other tests already cached some prefixes. + MediaWikiServices::getInstance()->resetServiceForTesting( 'InterwikiLookup' ); + return function () { // Tear down Hooks::clear( 'InterwikiLoadPrefix' ); + MediaWikiServices::getInstance()->resetServiceForTesting( 'InterwikiLookup' ); }; } diff --git a/tests/phpunit/MediaWikiTestCase.php b/tests/phpunit/MediaWikiTestCase.php index 4d424983c7..8b2d099227 100644 --- a/tests/phpunit/MediaWikiTestCase.php +++ b/tests/phpunit/MediaWikiTestCase.php @@ -1995,61 +1995,6 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase { return $loaded; } - /** - * Asserts that the given string is a valid HTML snippet. - * Wraps the given string in the required top level tags and - * then calls assertValidHtmlDocument(). - * The snippet is expected to be HTML 5. - * - * @since 1.23 - * - * @note Will mark the test as skipped if the "tidy" module is not installed. - * @note This ignores $wgUseTidy, so we can check for valid HTML even (and especially) - * when automatic tidying is disabled. - * - * @param string $html An HTML snippet (treated as the contents of the body tag). - */ - protected function assertValidHtmlSnippet( $html ) { - $html = 'test' . $html . ''; - $this->assertValidHtmlDocument( $html ); - } - - /** - * Asserts that the given string is valid HTML document. - * - * @since 1.23 - * - * @note Will mark the test as skipped if the "tidy" module is not installed. - * @note This ignores $wgUseTidy, so we can check for valid HTML even (and especially) - * when automatic tidying is disabled. - * - * @param string $html A complete HTML document - */ - protected function assertValidHtmlDocument( $html ) { - // Note: we only validate if the tidy PHP extension is available. - // In case wgTidyInternal is false, MWTidy would fall back to the command line version - // of tidy. In that case however, we can not reliably detect whether a failing validation - // is due to malformed HTML, or caused by tidy not being installed as a command line tool. - // That would cause all HTML assertions to fail on a system that has no tidy installed. - if ( !$GLOBALS['wgTidyInternal'] || !MWTidy::isEnabled() ) { - $this->markTestSkipped( 'Tidy extension not installed' ); - } - - $errorBuffer = ''; - MWTidy::checkErrors( $html, $errorBuffer ); - $allErrors = preg_split( '/[\r\n]+/', $errorBuffer ); - - // Filter Tidy warnings which aren't useful for us. - // Tidy eg. often cries about parameters missing which have actually - // been deprecated since HTML4, thus we should not care about them. - $errors = preg_grep( - '/^(.*Warning: (trimming empty|.* lacks ".*?" attribute).*|\s*)$/m', - $allErrors, PREG_GREP_INVERT - ); - - $this->assertEmpty( $errors, implode( "\n", $errors ) ); - } - /** * Used as a marker to prevent wfResetOutputBuffers from breaking PHPUnit. * @param string $buffer diff --git a/tests/phpunit/includes/api/ApiBlockTest.php b/tests/phpunit/includes/api/ApiBlockTest.php index c456e9abbb..374ea3cd6f 100644 --- a/tests/phpunit/includes/api/ApiBlockTest.php +++ b/tests/phpunit/includes/api/ApiBlockTest.php @@ -12,7 +12,6 @@ class ApiBlockTest extends ApiTestCase { protected function setUp() { parent::setUp(); - $this->doLogin(); $this->mUser = $this->getMutableTestUser()->getUser(); } diff --git a/tests/phpunit/includes/api/ApiDeleteTest.php b/tests/phpunit/includes/api/ApiDeleteTest.php index c9ce28e73a..0f2bcc6145 100644 --- a/tests/phpunit/includes/api/ApiDeleteTest.php +++ b/tests/phpunit/includes/api/ApiDeleteTest.php @@ -12,13 +12,6 @@ * @covers ApiDelete */ class ApiDeleteTest extends ApiTestCase { - - protected function setUp() { - parent::setUp(); - - $this->doLogin(); - } - public function testDelete() { $name = 'Help:' . ucfirst( __FUNCTION__ ); diff --git a/tests/phpunit/includes/api/ApiEditPageTest.php b/tests/phpunit/includes/api/ApiEditPageTest.php index 4790f6b727..c196338915 100644 --- a/tests/phpunit/includes/api/ApiEditPageTest.php +++ b/tests/phpunit/includes/api/ApiEditPageTest.php @@ -40,8 +40,6 @@ class ApiEditPageTest extends ApiTestCase { MWNamespace::clearCaches(); $wgContLang->resetNamespaces(); # reset namespace cache - - $this->doLogin(); } protected function tearDown() { diff --git a/tests/phpunit/includes/api/ApiMainTest.php b/tests/phpunit/includes/api/ApiMainTest.php index 8ffe4fcfb9..d17334bb06 100644 --- a/tests/phpunit/includes/api/ApiMainTest.php +++ b/tests/phpunit/includes/api/ApiMainTest.php @@ -46,7 +46,7 @@ class ApiMainTest extends ApiTestCase { */ private function getNonInternalApiMain( array $requestData, array $headers = [] ) { $req = $this->getMockBuilder( WebRequest::class ) - ->setMethods( [ 'response', 'getIP' ] ) + ->setMethods( [ 'response', 'getRawIP' ] ) ->getMock(); $response = new FauxResponse(); $req->method( 'response' )->willReturn( $response ); diff --git a/tests/phpunit/includes/api/ApiParseTest.php b/tests/phpunit/includes/api/ApiParseTest.php index e236437722..a04271f602 100644 --- a/tests/phpunit/includes/api/ApiParseTest.php +++ b/tests/phpunit/includes/api/ApiParseTest.php @@ -13,48 +13,136 @@ class ApiParseTest extends ApiTestCase { protected static $revIds = []; public function addDBDataOnce() { - $user = static::getTestSysop()->getUser(); $title = Title::newFromText( __CLASS__ ); - $page = WikiPage::factory( $title ); - $status = $page->doEditContent( - ContentHandler::makeContent( 'Test for revdel', $title, CONTENT_MODEL_WIKITEXT ), - __METHOD__ . ' Test for revdel', 0, false, $user - ); - if ( !$status->isOK() ) { - $this->fail( "Failed to create $title: " . $status->getWikiText( false, false, 'en' ) ); - } + $status = $this->editPage( __CLASS__, 'Test for revdel' ); self::$pageId = $status->value['revision']->getPage(); self::$revIds['revdel'] = $status->value['revision']->getId(); - $status = $page->doEditContent( - ContentHandler::makeContent( 'Test for oldid', $title, CONTENT_MODEL_WIKITEXT ), - __METHOD__ . ' Test for oldid', 0, false, $user - ); - if ( !$status->isOK() ) { - $this->fail( "Failed to edit $title: " . $status->getWikiText( false, false, 'en' ) ); - } + $status = $this->editPage( __CLASS__, 'Test for suppressed' ); + self::$revIds['suppressed'] = $status->value['revision']->getId(); + + $status = $this->editPage( __CLASS__, 'Test for oldid' ); self::$revIds['oldid'] = $status->value['revision']->getId(); - $status = $page->doEditContent( - ContentHandler::makeContent( 'Test for latest', $title, CONTENT_MODEL_WIKITEXT ), - __METHOD__ . ' Test for latest', 0, false, $user + $status = $this->editPage( __CLASS__, 'Test for latest' ); + self::$revIds['latest'] = $status->value['revision']->getId(); + + $this->revisionDelete( self::$revIds['revdel'] ); + $this->revisionDelete( + self::$revIds['suppressed'], + [ Revision::DELETED_TEXT => 1, Revision::DELETED_RESTRICTED => 1 ] ); - if ( !$status->isOK() ) { - $this->fail( "Failed to edit $title: " . $status->getWikiText( false, false, 'en' ) ); + + Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason + } + + /** + * Assert that the given result of calling $this->doApiRequest() with + * action=parse resulted in $html, accounting for the boilerplate that the + * parser adds around the parsed page. Also asserts that warnings match + * the provided $warning. + * + * @param string $html Expected HTML + * @param array $res Returned from doApiRequest() + * @param string|null $warnings Exact value of expected warnings, null for + * no warnings + */ + protected function assertParsedTo( $expected, array $res, $warnings = null ) { + $this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertSame' ] ); + } + + /** + * Same as above, but asserts that the HTML matches a regexp instead of a + * literal string match. + * + * @param string $html Expected HTML + * @param array $res Returned from doApiRequest() + * @param string|null $warnings Exact value of expected warnings, null for + * no warnings + */ + protected function assertParsedToRegExp( $expected, array $res, $warnings = null ) { + $this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertRegExp' ] ); + } + + private function doAssertParsedTo( $expected, array $res, $warnings, callable $callback ) { + $html = $res[0]['parse']['text']; + + $expectedStart = '
'; + $this->assertSame( $expectedStart, substr( $html, 0, strlen( $expectedStart ) ) ); + + $html = substr( $html, strlen( $expectedStart ) ); + + if ( $res[1]->getBool( 'disablelimitreport' ) ) { + $expectedEnd = "
"; + $this->assertSame( $expectedEnd, substr( $html, -strlen( $expectedEnd ) ) ); + + $html = substr( $html, 0, strlen( $html ) - strlen( $expectedEnd ) ); + } else { + $expectedEnd = '#\n)\n' . + ')\n' . + '(\n)\n)?$#s'; + $this->assertRegExp( $expectedEnd, $html ); + + $html = preg_replace( $expectedEnd, '', $html ); } - self::$revIds['latest'] = $status->value['revision']->getId(); - RevisionDeleter::createList( - 'revision', RequestContext::getMain(), $title, [ self::$revIds['revdel'] ] - )->setVisibility( [ - 'value' => [ - Revision::DELETED_TEXT => 1, + call_user_func( $callback, $expected, $html ); + + if ( $warnings === null ) { + $this->assertCount( 1, $res[0] ); + } else { + $this->assertCount( 2, $res[0] ); + // This deliberately fails if there are extra warnings + $this->assertSame( [ 'parse' => [ 'warnings' => $warnings ] ], $res[0]['warnings'] ); + } + } + + /** + * Set up an interwiki entry for testing. + */ + protected function setupInterwiki() { + $dbw = wfGetDB( DB_MASTER ); + $dbw->insert( + 'interwiki', + [ + 'iw_prefix' => 'madeuplanguage', + 'iw_url' => "https://example.com/wiki/$1", + 'iw_api' => '', + 'iw_wikiid' => '', + 'iw_local' => false, ], - 'comment' => 'Test for revdel', - ] ); + __METHOD__, + 'IGNORE' + ); - Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason + $this->setMwGlobals( 'wgExtraInterlanguageLinkPrefixes', [ 'madeuplanguage' ] ); + $this->tablesUsed[] = 'interwiki'; + } + + /** + * Set up a skin for testing. + * + * @todo Should this code be in MediaWikiTestCase or something? + */ + protected function setupSkin() { + $factory = new SkinFactory(); + $factory->register( 'testing', 'Testing', function () { + $skin = $this->getMockBuilder( SkinFallback::class ) + ->setMethods( [ 'getDefaultModules', 'setupSkinUserCss' ] ) + ->getMock(); + $skin->expects( $this->once() )->method( 'getDefaultModules' ) + ->willReturn( [ + 'core' => [ 'foo', 'bar' ], + 'content' => [ 'baz' ] + ] ); + $skin->expects( $this->once() )->method( 'setupSkinUserCss' ) + ->will( $this->returnCallback( function ( OutputPage $out ) { + $out->addModuleStyles( 'foo.styles' ); + } ) ); + return $skin; + } ); + $this->setService( 'SkinFactory', $factory ); } public function testParseByName() { @@ -62,14 +150,14 @@ class ApiParseTest extends ApiTestCase { 'action' => 'parse', 'page' => __CLASS__, ] ); - $this->assertContains( 'Test for latest', $res[0]['parse']['text'] ); + $this->assertParsedTo( "

Test for latest\n

", $res ); $res = $this->doApiRequest( [ 'action' => 'parse', 'page' => __CLASS__, 'disablelimitreport' => 1, ] ); - $this->assertContains( 'Test for latest', $res[0]['parse']['text'] ); + $this->assertParsedTo( "

Test for latest\n

", $res ); } public function testParseById() { @@ -77,7 +165,7 @@ class ApiParseTest extends ApiTestCase { 'action' => 'parse', 'pageid' => self::$pageId, ] ); - $this->assertContains( 'Test for latest', $res[0]['parse']['text'] ); + $this->assertParsedTo( "

Test for latest\n

", $res ); } public function testParseByOldId() { @@ -85,36 +173,46 @@ class ApiParseTest extends ApiTestCase { 'action' => 'parse', 'oldid' => self::$revIds['oldid'], ] ); - $this->assertContains( 'Test for oldid', $res[0]['parse']['text'] ); + $this->assertParsedTo( "

Test for oldid\n

", $res ); $this->assertArrayNotHasKey( 'textdeleted', $res[0]['parse'] ); $this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] ); } - public function testParseRevDel() { - $user = static::getTestUser()->getUser(); - $sysop = static::getTestSysop()->getUser(); - - try { - $this->doApiRequest( [ - 'action' => 'parse', - 'oldid' => self::$revIds['revdel'], - ], null, null, $user ); - $this->fail( "API did not return an error as expected" ); - } catch ( ApiUsageException $ex ) { - $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'permissiondenied' ), - "API failed with error 'permissiondenied'" ); - } - + public function testRevDel() { $res = $this->doApiRequest( [ 'action' => 'parse', 'oldid' => self::$revIds['revdel'], - ], null, null, $sysop ); - $this->assertContains( 'Test for revdel', $res[0]['parse']['text'] ); + ] ); + + $this->assertParsedTo( "

Test for revdel\n

", $res ); $this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] ); $this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] ); } - public function testParseNonexistentPage() { + public function testRevDelNoPermission() { + $this->setExpectedException( ApiUsageException::class, + "You don't have permission to view deleted revision text." ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'oldid' => self::$revIds['revdel'], + ], null, null, static::getTestUser()->getUser() ); + } + + public function testSuppressed() { + $this->setGroupPermissions( 'sysop', 'viewsuppressed', true ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'oldid' => self::$revIds['suppressed'] + ] ); + + $this->assertParsedTo( "

Test for suppressed\n

", $res ); + $this->assertArrayHasKey( 'textsuppressed', $res[0]['parse'] ); + $this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] ); + } + + public function testNonexistentPage() { try { $this->doApiRequest( [ 'action' => 'parse', @@ -130,24 +228,446 @@ class ApiParseTest extends ApiTestCase { } } - public function testSkinModules() { - $factory = new SkinFactory(); - $factory->register( 'testing', 'Testing', function () { - $skin = $this->getMockBuilder( SkinFallback::class ) - ->setMethods( [ 'getDefaultModules', 'setupSkinUserCss' ] ) - ->getMock(); - $skin->expects( $this->once() )->method( 'getDefaultModules' ) - ->willReturn( [ - 'core' => [ 'foo', 'bar' ], - 'content' => [ 'baz' ] - ] ); - $skin->expects( $this->once() )->method( 'setupSkinUserCss' ) - ->will( $this->returnCallback( function ( OutputPage $out ) { - $out->addModuleStyles( 'foo.styles' ); - } ) ); - return $skin; - } ); - $this->setService( 'SkinFactory', $factory ); + public function testTitleProvided() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => 'Some interesting page', + 'text' => '{{PAGENAME}} has attracted my attention', + ] ); + + $this->assertParsedTo( "

Some interesting page has attracted my attention\n

", $res ); + } + + public function testSection() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( $name, + "Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'page' => $name, + 'section' => 1, + ] ); + + $this->assertParsedToRegExp( '!

.*Section 1.*

\n

Content 1\n

!', $res ); + } + + public function testInvalidSection() { + $this->setExpectedException( ApiUsageException::class, + 'The "section" parameter must be a valid section ID or "new".' ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'section' => 'T-new', + ] ); + } + + public function testSectionNoContent() { + $name = ucfirst( __FUNCTION__ ); + + $status = $this->editPage( $name, + "Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" ); + + $this->setExpectedException( ApiUsageException::class, + "Missing content for page ID {$status->value['revision']->getPage()}." ); + + $this->db->delete( 'revision', [ 'rev_id' => $status->value['revision']->getId() ] ); + + // Suppress warning in WikiPage::getContentModel + Wikimedia\suppressWarnings(); + try { + $this->doApiRequest( [ + 'action' => 'parse', + 'page' => $name, + 'section' => 1, + ] ); + } finally { + Wikimedia\restoreWarnings(); + } + } + + public function testNewSectionWithPage() { + $this->setExpectedException( ApiUsageException::class, + '"section=new" cannot be combined with the "oldid", "pageid" or "page" ' . + 'parameters. Please use "title" and "text".' ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'page' => __CLASS__, + 'section' => 'new', + ] ); + } + + public function testNonexistentOldId() { + $this->setExpectedException( ApiUsageException::class, + 'There is no revision with ID 2147483647.' ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'oldid' => pow( 2, 31 ) - 1, + ] ); + } + + public function testUnfollowedRedirect() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( $name, "#REDIRECT [[$name 2]]" ); + $this->editPage( "$name 2", "Some ''text''" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'page' => $name, + ] ); + + // Can't use assertParsedTo because the parser output is different for + // redirects + $this->assertRegExp( "/Redirect to:.*$name 2/", $res[0]['parse']['text'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testFollowedRedirect() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( $name, "#REDIRECT [[$name 2]]" ); + $this->editPage( "$name 2", "Some ''text''" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'page' => $name, + 'redirects' => true, + ] ); + + $this->assertParsedTo( "

Some text\n

", $res ); + } + + public function testFollowedRedirectById() { + $name = ucfirst( __FUNCTION__ ); + + $id = $this->editPage( $name, "#REDIRECT [[$name 2]]" )->value['revision']->getPage(); + $this->editPage( "$name 2", "Some ''text''" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'pageid' => $id, + 'redirects' => true, + ] ); + + $this->assertParsedTo( "

Some text\n

", $res ); + } + + public function testInvalidTitle() { + $this->setExpectedException( ApiUsageException::class, 'Bad title "|".' ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'title' => '|', + ] ); + } + + public function testTitleWithNonexistentRevId() { + $this->setExpectedException( ApiUsageException::class, + 'There is no revision with ID 2147483647.' ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'revid' => pow( 2, 31 ) - 1, + ] ); + } + + public function testTitleWithNonMatchingRevId() { + $name = ucfirst( __FUNCTION__ ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => $name, + 'revid' => self::$revIds['latest'], + 'text' => 'Some text', + ] ); + + $this->assertParsedTo( "

Some text\n

", $res, + 'r' . self::$revIds['latest'] . " is not a revision of $name." ); + } + + public function testRevId() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'revid' => self::$revIds['latest'], + 'text' => 'My revid is {{REVISIONID}}!', + ] ); + + $this->assertParsedTo( "

My revid is " . self::$revIds['latest'] . "!\n

", $res ); + } + + public function testTitleNoText() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => 'Special:AllPages', + ] ); + + $this->assertParsedTo( '', $res, + '"title" used without "text", and parsed page properties were requested. ' . + 'Did you mean to use "page" instead of "title"?' ); + } + + public function testRevidNoText() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'revid' => self::$revIds['latest'], + ] ); + + $this->assertParsedTo( '', $res, + '"revid" used without "text", and parsed page properties were requested. ' . + 'Did you mean to use "oldid" instead of "revid"?' ); + } + + public function testTextNoContentModel() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'text' => "Some ''text''", + ] ); + + $this->assertParsedTo( "

Some text\n

", $res, + 'No "title" or "contentmodel" was given, assuming wikitext.' ); + } + + public function testSerializationError() { + $this->setExpectedException( APIUsageException::class, + 'Content serialization failed: Could not unserialize content' ); + + $this->mergeMwGlobalArrayValue( 'wgContentHandlers', + [ 'testing-serialize-error' => 'DummySerializeErrorContentHandler' ] ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'text' => "Some ''text''", + 'contentmodel' => 'testing-serialize-error', + ] ); + } + + public function testNewSection() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'section' => 'new', + 'sectiontitle' => 'Title', + 'text' => 'Content', + ] ); + + $this->assertParsedToRegExp( '!

.*Title.*

\n

Content\n

!', $res ); + } + + public function testExistingSection() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'section' => 1, + 'text' => "Intro\n\n== Section 1 ==\n\nContent\n\n== Section 2 ==\n\nMore content", + ] ); + + $this->assertParsedToRegExp( '!

.*Section 1.*

\n

Content\n

!', $res ); + } + + public function testNoPst() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( "Template:$name", "Template ''text''" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'text' => "{{subst:$name}}", + 'contentmodel' => 'wikitext', + ] ); + + $this->assertParsedTo( "

{{subst:$name}}\n

", $res ); + } + + public function testPst() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( "Template:$name", "Template ''text''" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'pst' => '', + 'text' => "{{subst:$name}}", + 'contentmodel' => 'wikitext', + 'prop' => 'text|wikitext', + ] ); + + $this->assertParsedTo( "

Template text\n

", $res ); + $this->assertSame( "{{subst:$name}}", $res[0]['parse']['wikitext'] ); + } + + public function testOnlyPst() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( "Template:$name", "Template ''text''" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'onlypst' => '', + 'text' => "{{subst:$name}}", + 'contentmodel' => 'wikitext', + 'prop' => 'text|wikitext', + 'summary' => 'Summary', + ] ); + + $this->assertSame( + [ 'parse' => [ + 'text' => "Template ''text''", + 'wikitext' => "{{subst:$name}}", + 'parsedsummary' => 'Summary', + ] ], + $res[0] + ); + } + + public function testHeadHtml() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'page' => __CLASS__, + 'prop' => 'headhtml', + ] ); + + // Just do a rough sanity check + $this->assertRegExp( '#.*assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testCategoriesHtml() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( $name, "[[Category:$name]]" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'page' => $name, + 'prop' => 'categorieshtml', + ] ); + + $this->assertRegExp( "#Category.*Category:$name.*$name#", + $res[0]['parse']['categorieshtml'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testEffectiveLangLinks() { + $hookRan = false; + $this->setTemporaryHook( 'LanguageLinks', + function () use ( &$hookRan ) { + $hookRan = true; + } + ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => '[[zh:' . __CLASS__ . ']]', + 'effectivelanglinks' => '', + ] ); + + $this->assertTrue( $hookRan ); + $this->assertSame( 'The parameter "effectivelanglinks" has been deprecated.', + $res[0]['warnings']['parse']['warnings'] ); + } + + /** + * @param array $arr Extra params to add to API request + */ + private function doTestLangLinks( array $arr = [] ) { + $this->setupInterwiki(); + + $res = $this->doApiRequest( array_merge( [ + 'action' => 'parse', + 'title' => 'Omelette', + 'text' => '[[madeuplanguage:Omelette]]', + 'prop' => 'langlinks', + ], $arr ) ); + + $langLinks = $res[0]['parse']['langlinks']; + + $this->assertCount( 1, $langLinks ); + $this->assertSame( 'madeuplanguage', $langLinks[0]['lang'] ); + $this->assertSame( 'Omelette', $langLinks[0]['title'] ); + $this->assertSame( 'https://example.com/wiki/Omelette', $langLinks[0]['url'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testLangLinks() { + $this->doTestLangLinks(); + } + + public function testLangLinksWithSkin() { + $this->setupSkin(); + $this->doTestLangLinks( [ 'useskin' => 'testing' ] ); + } + + public function testHeadItems() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => '', + 'prop' => 'headitems', + ] ); + + $this->assertSame( [], $res[0]['parse']['headitems'] ); + $this->assertSame( + '"prop=headitems" is deprecated since MediaWiki 1.28. ' . + 'Use "prop=headhtml" when creating new HTML documents, ' . + 'or "prop=modules|jsconfigvars" when updating a document client-side.', + $res[0]['warnings']['parse']['warnings'] + ); + } + + public function testHeadItemsWithSkin() { + $this->setupSkin(); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => '', + 'prop' => 'headitems', + 'useskin' => 'testing', + ] ); + + $this->assertSame( [], $res[0]['parse']['headitems'] ); + $this->assertSame( + '"prop=headitems" is deprecated since MediaWiki 1.28. ' . + 'Use "prop=headhtml" when creating new HTML documents, ' . + 'or "prop=modules|jsconfigvars" when updating a document client-side.', + $res[0]['warnings']['parse']['warnings'] + ); + } + + public function testModules() { + $this->setTemporaryHook( 'ParserAfterParse', + function ( $parser ) { + $output = $parser->getOutput(); + $output->addModules( [ 'foo', 'bar' ] ); + $output->addModuleScripts( [ 'baz', 'quuz' ] ); + $output->addModuleStyles( [ 'aaa', 'zzz' ] ); + $output->addJsConfigVars( [ 'x' => 'y', 'z' => -3 ] ); + } + ); + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => 'Content', + 'prop' => 'modules|jsconfigvars|encodedjsconfigvars', + ] ); + + $this->assertSame( [ 'foo', 'bar' ], $res[0]['parse']['modules'] ); + $this->assertSame( [ 'baz', 'quuz' ], $res[0]['parse']['modulescripts'] ); + $this->assertSame( [ 'aaa', 'zzz' ], $res[0]['parse']['modulestyles'] ); + $this->assertSame( [ 'x' => 'y', 'z' => -3 ], $res[0]['parse']['jsconfigvars'] ); + $this->assertSame( '{"x":"y","z":-3}', $res[0]['parse']['encodedjsconfigvars'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testModulesWithSkin() { + $this->setupSkin(); $res = $this->doApiRequest( [ 'action' => 'parse', @@ -170,5 +690,160 @@ class ApiParseTest extends ApiTestCase { $res[0]['parse']['modulestyles'], 'resp.parse.modulestyles' ); + $this->assertSame( + [ 'parse' => + [ 'warnings' => + 'Property "modules" was set but not "jsconfigvars" or ' . + '"encodedjsconfigvars". Configuration variables are necessary for ' . + 'proper module usage.' + ] + ], + $res[0]['warnings'] + ); + } + + public function testIndicators() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => + 'BBB!Some textaaa', + 'prop' => 'indicators', + ] ); + + $this->assertSame( + // It seems we return in markup order and not display order + [ 'b' => 'BBB!', 'a' => 'aaa' ], + $res[0]['parse']['indicators'] + ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testIndicatorsWithSkin() { + $this->setupSkin(); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => + 'BBB!Some textaaa', + 'prop' => 'indicators', + 'useskin' => 'testing', + ] ); + + $this->assertSame( + // Now we return in display order rather than markup order + [ 'a' => 'aaa', 'b' => 'BBB!' ], + $res[0]['parse']['indicators'] + ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testIwlinks() { + $this->setupInterwiki(); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => 'Omelette', + 'text' => '[[:madeuplanguage:Omelette]][[madeuplanguage:Spaghetti]]', + 'prop' => 'iwlinks', + ] ); + + $iwlinks = $res[0]['parse']['iwlinks']; + + $this->assertCount( 1, $iwlinks ); + $this->assertSame( 'madeuplanguage', $iwlinks[0]['prefix'] ); + $this->assertSame( 'https://example.com/wiki/Omelette', $iwlinks[0]['url'] ); + $this->assertSame( 'madeuplanguage:Omelette', $iwlinks[0]['title'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testLimitReports() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'pageid' => self::$pageId, + 'prop' => 'limitreportdata|limitreporthtml', + ] ); + + // We don't bother testing the actual values here + $this->assertInternalType( 'array', $res[0]['parse']['limitreportdata'] ); + $this->assertInternalType( 'string', $res[0]['parse']['limitreporthtml'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testParseTreeNonWikitext() { + $this->setExpectedException( ApiUsageException::class, + '"prop=parsetree" is only supported for wikitext content.' ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'text' => '', + 'contentmodel' => 'json', + 'prop' => 'parsetree', + ] ); + } + + public function testParseTree() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'text' => "Some ''text'' is {{nice|to have|i=think}}", + 'contentmodel' => 'wikitext', + 'prop' => 'parsetree', + ] ); + + // Preprocessor_DOM and Preprocessor_Hash give different results here, + // so we'll accept either + $this->assertRegExp( + '#^Some \'\'text\'\' is $#', + $res[0]['parse']['parsetree'] + ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testDisableTidy() { + $this->setMwGlobals( 'wgTidyConfig', [ 'driver' => 'RemexHtml' ] ); + + // Check that disabletidy doesn't have an effect just because tidying + // doesn't work for some other reason + $res1 = $this->doApiRequest( [ + 'action' => 'parse', + 'text' => "Mixed up", + 'contentmodel' => 'wikitext', + ] ); + $this->assertParsedTo( "

Mixed up\n

", $res1 ); + + $res2 = $this->doApiRequest( [ + 'action' => 'parse', + 'text' => "Mixed up", + 'contentmodel' => 'wikitext', + 'disabletidy' => '', + ] ); + + $this->assertParsedTo( "

Mixed up\n

", $res2 ); + } + + public function testFormatCategories() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( "Category:$name", 'Content' ); + $this->editPage( 'Category:Hidden', '__HIDDENCAT__' ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => "[[Category:$name]][[Category:Foo|Sort me]][[Category:Hidden]]", + 'prop' => 'categories', + ] ); + + $this->assertSame( + [ [ 'sortkey' => '', 'category' => $name ], + [ 'sortkey' => 'Sort me', 'category' => 'Foo', 'missing' => true ], + [ 'sortkey' => '', 'category' => 'Hidden', 'hidden' => true ] ], + $res[0]['parse']['categories'] + ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); } } diff --git a/tests/phpunit/includes/api/ApiPurgeTest.php b/tests/phpunit/includes/api/ApiPurgeTest.php index 9e1d3a189b..96d9a38488 100644 --- a/tests/phpunit/includes/api/ApiPurgeTest.php +++ b/tests/phpunit/includes/api/ApiPurgeTest.php @@ -9,11 +9,6 @@ */ class ApiPurgeTest extends ApiTestCase { - protected function setUp() { - parent::setUp(); - $this->doLogin(); - } - /** * @group Broken */ diff --git a/tests/phpunit/includes/api/ApiQueryAllPagesTest.php b/tests/phpunit/includes/api/ApiQueryAllPagesTest.php index b482c3189a..2d89aa5433 100644 --- a/tests/phpunit/includes/api/ApiQueryAllPagesTest.php +++ b/tests/phpunit/includes/api/ApiQueryAllPagesTest.php @@ -8,16 +8,10 @@ * @covers ApiQueryAllPages */ class ApiQueryAllPagesTest extends ApiTestCase { - - protected function setUp() { - parent::setUp(); - $this->doLogin(); - } - /** - *Test T27702 - *Prefixes of API search requests are not handled with case sensitivity and may result - *in wrong search results + * Test T27702 + * Prefixes of API search requests are not handled with case sensitivity and may result + * in wrong search results */ public function testPrefixNormalizationSearchBug() { $title = Title::newFromText( 'Category:Template:xyz' ); diff --git a/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php b/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php index 24b7500983..5b43dd1b83 100644 --- a/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php +++ b/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php @@ -23,7 +23,6 @@ class ApiQueryRecentChangesIntegrationTest extends ApiTestCase { parent::setUp(); self::$users['ApiQueryRecentChangesIntegrationTestUser'] = $this->getMutableTestUser(); - $this->doLogin( 'ApiQueryRecentChangesIntegrationTestUser' ); wfGetDB( DB_MASTER )->delete( 'recentchanges', '*', __METHOD__ ); } @@ -129,7 +128,8 @@ class ApiQueryRecentChangesIntegrationTest extends ApiTestCase { $params ), null, - false + false, + $this->getLoggedInTestUser() ); } @@ -138,7 +138,10 @@ class ApiQueryRecentChangesIntegrationTest extends ApiTestCase { array_merge( [ 'action' => 'query', 'generator' => 'recentchanges' ], $params - ) + ), + null, + false, + $this->getLoggedInTestUser() ); } diff --git a/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php b/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php index f973281c79..5f59d6fba4 100644 --- a/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php +++ b/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php @@ -23,7 +23,6 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { parent::setUp(); self::$users['ApiQueryWatchlistIntegrationTestUser'] = $this->getMutableTestUser(); self::$users['ApiQueryWatchlistIntegrationTestUser2'] = $this->getMutableTestUser(); - $this->doLogin( 'ApiQueryWatchlistIntegrationTestUser' ); } private function getLoggedInTestUser() { @@ -163,6 +162,9 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { } private function doListWatchlistRequest( array $params = [], $user = null ) { + if ( $user === null ) { + $user = $this->getLoggedInTestUser(); + } return $this->doApiRequest( array_merge( [ 'action' => 'query', 'list' => 'watchlist' ], @@ -176,7 +178,7 @@ class ApiQueryWatchlistIntegrationTest extends ApiTestCase { array_merge( [ 'action' => 'query', 'generator' => 'watchlist' ], $params - ) + ), null, false, $this->getLoggedInTestUser() ); } diff --git a/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php b/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php index 0f01664e72..2af63c4983 100644 --- a/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php +++ b/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php @@ -17,7 +17,6 @@ class ApiQueryWatchlistRawIntegrationTest extends ApiTestCase { = $this->getMutableTestUser(); self::$users['ApiQueryWatchlistRawIntegrationTestUser2'] = $this->getMutableTestUser(); - $this->doLogin( 'ApiQueryWatchlistRawIntegrationTestUser' ); } private function getLoggedInTestUser() { @@ -36,14 +35,14 @@ class ApiQueryWatchlistRawIntegrationTest extends ApiTestCase { return $this->doApiRequest( array_merge( [ 'action' => 'query', 'list' => 'watchlistraw' ], $params - ) ); + ), null, false, $this->getLoggedInTestUser() ); } private function doGeneratorWatchlistRawRequest( array $params = [] ) { return $this->doApiRequest( array_merge( [ 'action' => 'query', 'generator' => 'watchlistraw' ], $params - ) ); + ), null, false, $this->getLoggedInTestUser() ); } private function getItemsFromApiResponse( array $response ) { diff --git a/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php b/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php index ef4f5139e4..dacd48f623 100644 --- a/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php +++ b/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php @@ -13,7 +13,6 @@ class ApiSetNotificationTimestampIntegrationTest extends ApiTestCase { protected function setUp() { parent::setUp(); self::$users[__CLASS__] = new TestUser( __CLASS__ ); - $this->doLogin( __CLASS__ ); } public function testStuff() { diff --git a/tests/phpunit/includes/api/ApiStashEditTest.php b/tests/phpunit/includes/api/ApiStashEditTest.php index e2462c6138..60cda09030 100644 --- a/tests/phpunit/includes/api/ApiStashEditTest.php +++ b/tests/phpunit/includes/api/ApiStashEditTest.php @@ -9,7 +9,6 @@ class ApiStashEditTest extends ApiTestCase { public function testBasicEdit() { - $this->doLogin(); $apiResult = $this->doApiRequestWithToken( [ 'action' => 'stashedit', diff --git a/tests/phpunit/includes/api/ApiTestCase.php b/tests/phpunit/includes/api/ApiTestCase.php index 00ef2ea122..a5ee7ddb8c 100644 --- a/tests/phpunit/includes/api/ApiTestCase.php +++ b/tests/phpunit/includes/api/ApiTestCase.php @@ -56,6 +56,28 @@ abstract class ApiTestCase extends MediaWikiLangTestCase { return $page->doEditContent( ContentHandler::makeContent( $text, $title ), $summary ); } + /** + * Revision-deletes a revision. + * + * @param Revision|int $rev Revision to delete + * @param array $value Keys are Revision::DELETED_* flags. Values are 1 to set the bit, 0 to + * clear, -1 to leave alone. (All other values also clear the bit.) + * @param string $comment Deletion comment + */ + protected function revisionDelete( + $rev, array $value = [ Revision::DELETED_TEXT => 1 ], $comment = '' + ) { + if ( is_int( $rev ) ) { + $rev = Revision::newFromId( $rev ); + } + RevisionDeleter::createList( + 'revision', RequestContext::getMain(), $rev->getTitle(), [ $rev->getId() ] + )->setVisibility( [ + 'value' => $value, + 'comment' => $comment, + ] ); + } + /** * Does the API request and returns the result. * @@ -151,40 +173,29 @@ abstract class ApiTestCase extends MediaWikiLangTestCase { return $this->doApiRequest( $params, $session, false, $user, $tokenType ); } - protected function doLogin( $testUser = 'sysop' ) { + /** + * Previously this would do API requests to log in, as well as setting $wgUser and the request + * context's user. The API requests are unnecessary, and the global-setting is unwanted, so + * this method should not be called. Instead, pass appropriate User values directly to + * functions that need them. For functions that still rely on $wgUser, set that directly. If + * you just want to log in the test sysop user, don't do anything -- that's the default. + * + * @param TestUser|string $testUser Object, or key to self::$users such as 'sysop' or 'uploader' + * @deprecated since 1.31 + */ + protected function doLogin( $testUser = null ) { + global $wgUser; + if ( $testUser === null ) { $testUser = static::getTestSysop(); } elseif ( is_string( $testUser ) && array_key_exists( $testUser, self::$users ) ) { - $testUser = self::$users[ $testUser ]; + $testUser = self::$users[$testUser]; } elseif ( !$testUser instanceof TestUser ) { - throw new MWException( "Can not log in to undefined user $testUser" ); + throw new MWException( "Can't log in to undefined user $testUser" ); } - $data = $this->doApiRequest( [ - 'action' => 'login', - 'lgname' => $testUser->getUser()->getName(), - 'lgpassword' => $testUser->getPassword() ] ); - - $token = $data[0]['login']['token']; - - $data = $this->doApiRequest( - [ - 'action' => 'login', - 'lgtoken' => $token, - 'lgname' => $testUser->getUser()->getName(), - 'lgpassword' => $testUser->getPassword(), - ], - $data[2] - ); - - if ( $data[0]['login']['result'] === 'Success' ) { - // DWIM - global $wgUser; - $wgUser = $testUser->getUser(); - RequestContext::getMain()->setUser( $wgUser ); - } - - return $data; + $wgUser = $testUser->getUser(); + RequestContext::getMain()->setUser( $wgUser ); } protected function getTokenList( TestUser $user, $session = null ) { diff --git a/tests/phpunit/includes/api/ApiUnblockTest.php b/tests/phpunit/includes/api/ApiUnblockTest.php index 971b63c3d4..d20de0dc22 100644 --- a/tests/phpunit/includes/api/ApiUnblockTest.php +++ b/tests/phpunit/includes/api/ApiUnblockTest.php @@ -8,11 +8,6 @@ * @covers ApiUnblock */ class ApiUnblockTest extends ApiTestCase { - protected function setUp() { - parent::setUp(); - $this->doLogin(); - } - /** * @expectedException ApiUsageException */ @@ -22,10 +17,7 @@ class ApiUnblockTest extends ApiTestCase { 'action' => 'unblock', 'user' => 'UTApiBlockee', 'reason' => 'Some reason', - ], - null, - false, - self::$users['sysop']->getUser() + ] ); } } diff --git a/tests/phpunit/includes/api/ApiWatchTest.php b/tests/phpunit/includes/api/ApiWatchTest.php index b663dc7324..6d64a178df 100644 --- a/tests/phpunit/includes/api/ApiWatchTest.php +++ b/tests/phpunit/includes/api/ApiWatchTest.php @@ -9,11 +9,6 @@ * @covers ApiWatch */ class ApiWatchTest extends ApiTestCase { - protected function setUp() { - parent::setUp(); - $this->doLogin(); - } - function getTokens() { return $this->getTokenList( self::$users['sysop'] ); } diff --git a/tests/phpunit/includes/api/query/ApiQueryTest.php b/tests/phpunit/includes/api/query/ApiQueryTest.php index 88a2e62f46..de8d8156f3 100644 --- a/tests/phpunit/includes/api/query/ApiQueryTest.php +++ b/tests/phpunit/includes/api/query/ApiQueryTest.php @@ -9,7 +9,6 @@ class ApiQueryTest extends ApiTestCase { protected function setUp() { parent::setUp(); - $this->doLogin(); // Setup apiquerytestiw: as interwiki prefix $this->setMwGlobals( 'wgHooks', [ diff --git a/tests/phpunit/includes/auth/AuthManagerTest.php b/tests/phpunit/includes/auth/AuthManagerTest.php index 211eba00b7..cc1624874f 100644 --- a/tests/phpunit/includes/auth/AuthManagerTest.php +++ b/tests/phpunit/includes/auth/AuthManagerTest.php @@ -879,14 +879,10 @@ class AuthManagerTest extends \MediaWikiTestCase { ); $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) ->will( $this->returnValue( $key ) ); - $mocks[$key . '2'] = $this->getMockForAbstractClass( - "MediaWiki\\Auth\\$class", [], "Mock$class" - ); + $mocks[$key . '2'] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" ); $mocks[$key . '2']->expects( $this->any() )->method( 'getUniqueId' ) ->will( $this->returnValue( $key . '2' ) ); - $mocks[$key . '3'] = $this->getMockForAbstractClass( - "MediaWiki\\Auth\\$class", [], "Mock$class" - ); + $mocks[$key . '3'] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" ); $mocks[$key . '3']->expects( $this->any() )->method( 'getUniqueId' ) ->will( $this->returnValue( $key . '3' ) ); } @@ -1901,9 +1897,7 @@ class AuthManagerTest extends \MediaWikiTestCase { ) ); for ( $i = 2; $i <= 3; $i++ ) { - $mocks[$key . $i] = $this->getMockForAbstractClass( - "MediaWiki\\Auth\\$class", [], "Mock$class" - ); + $mocks[$key . $i] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" ); $mocks[$key . $i]->expects( $this->any() )->method( 'getUniqueId' ) ->will( $this->returnValue( $key . $i ) ); $mocks[$key . $i]->expects( $this->any() )->method( 'testUserForCreation' ) @@ -2368,9 +2362,7 @@ class AuthManagerTest extends \MediaWikiTestCase { $mocks = []; foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { $class = ucfirst( $key ) . 'AuthenticationProvider'; - $mocks[$key] = $this->getMockForAbstractClass( - "MediaWiki\\Auth\\$class", [], "Mock$class" - ); + $mocks[$key] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" ); $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) ->will( $this->returnValue( $key ) ); } @@ -2848,9 +2840,11 @@ class AuthManagerTest extends \MediaWikiTestCase { $mocks = []; foreach ( [ 'pre', 'primary', 'secondary' ] as $key ) { $class = ucfirst( $key ) . 'AuthenticationProvider'; - $mocks[$key] = $this->getMockForAbstractClass( - "MediaWiki\\Auth\\$class", [], "Mock$class" - ); + $mocks[$key] = $this->getMockBuilder( "MediaWiki\\Auth\\$class" ) + ->setMethods( [ + 'getUniqueId', 'getAuthenticationRequests', 'providerAllowsAuthenticationDataChange', + ] ) + ->getMockForAbstractClass(); $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) ->will( $this->returnValue( $key ) ); $mocks[$key]->expects( $this->any() )->method( 'getAuthenticationRequests' ) @@ -2868,9 +2862,12 @@ class AuthManagerTest extends \MediaWikiTestCase { PrimaryAuthenticationProvider::TYPE_LINK ] as $type ) { $class = 'PrimaryAuthenticationProvider'; - $mocks["primary-$type"] = $this->getMockForAbstractClass( - "MediaWiki\\Auth\\$class", [], "Mock$class" - ); + $mocks["primary-$type"] = $this->getMockBuilder( "MediaWiki\\Auth\\$class" ) + ->setMethods( [ + 'getUniqueId', 'accountCreationType', 'getAuthenticationRequests', + 'providerAllowsAuthenticationDataChange', + ] ) + ->getMockForAbstractClass(); $mocks["primary-$type"]->expects( $this->any() )->method( 'getUniqueId' ) ->will( $this->returnValue( "primary-$type" ) ); $mocks["primary-$type"]->expects( $this->any() )->method( 'accountCreationType' ) @@ -2885,9 +2882,12 @@ class AuthManagerTest extends \MediaWikiTestCase { $this->primaryauthMocks[] = $mocks["primary-$type"]; } - $mocks['primary2'] = $this->getMockForAbstractClass( - PrimaryAuthenticationProvider::class, [], "MockPrimaryAuthenticationProvider" - ); + $mocks['primary2'] = $this->getMockBuilder( PrimaryAuthenticationProvider::class ) + ->setMethods( [ + 'getUniqueId', 'accountCreationType', 'getAuthenticationRequests', + 'providerAllowsAuthenticationDataChange', + ] ) + ->getMockForAbstractClass(); $mocks['primary2']->expects( $this->any() )->method( 'getUniqueId' ) ->will( $this->returnValue( 'primary2' ) ); $mocks['primary2']->expects( $this->any() )->method( 'accountCreationType' ) @@ -3138,9 +3138,7 @@ class AuthManagerTest extends \MediaWikiTestCase { $mocks = []; foreach ( [ 'primary', 'secondary' ] as $key ) { $class = ucfirst( $key ) . 'AuthenticationProvider'; - $mocks[$key] = $this->getMockForAbstractClass( - "MediaWiki\\Auth\\$class", [], "Mock$class" - ); + $mocks[$key] = $this->getMockForAbstractClass( "MediaWiki\\Auth\\$class" ); $mocks[$key]->expects( $this->any() )->method( 'getUniqueId' ) ->will( $this->returnValue( $key ) ); $mocks[$key]->expects( $this->any() )->method( 'providerAllowsPropertyChange' ) @@ -3224,8 +3222,7 @@ class AuthManagerTest extends \MediaWikiTestCase { public function testAutoCreateFailOnLogin() { $username = self::usernameForCreation(); - $mock = $this->getMockForAbstractClass( - PrimaryAuthenticationProvider::class, [], "MockPrimaryAuthenticationProvider" ); + $mock = $this->getMockForAbstractClass( PrimaryAuthenticationProvider::class ); $mock->expects( $this->any() )->method( 'getUniqueId' )->will( $this->returnValue( 'primary' ) ); $mock->expects( $this->any() )->method( 'beginPrimaryAuthentication' ) ->will( $this->returnValue( AuthenticationResponse::newPass( $username ) ) ); diff --git a/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php b/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php index 14e2e27de8..4c0ca04fef 100644 --- a/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php +++ b/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php @@ -158,7 +158,8 @@ class KafkaHandlerTest extends MediaWikiTestCase { ->method( 'send' ) ->will( $this->returnValue( true ) ); // evil hax - TestingAccessWrapper::newFromObject( $mockMethod )->matcher->parametersMatcher = + $matcher = TestingAccessWrapper::newFromObject( $mockMethod )->matcher; + TestingAccessWrapper::newFromObject( $matcher )->parametersMatcher = new \PHPUnit_Framework_MockObject_Matcher_ConsecutiveParameters( [ [ $this->anything(), $this->anything(), [ 'words' ] ], [ $this->anything(), $this->anything(), [ 'lines' ] ] diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php index 378935c79a..93192d01fb 100644 --- a/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php @@ -138,12 +138,15 @@ class DatabaseMysqlBaseTest extends PHPUnit\Framework\TestCase { $db->listViews( '' ) ); } + /** + * @covers Wikimedia\Rdbms\MySQLMasterPos + */ public function testBinLogName() { $pos = new MySQLMasterPos( "db1052.2424/4643", 1 ); - $this->assertEquals( "db1052", $pos->binlog ); + $this->assertEquals( "db1052", $pos->getLogName() ); $this->assertEquals( "db1052.2424", $pos->getLogFile() ); - $this->assertEquals( [ 2424, 4643 ], $pos->pos ); + $this->assertEquals( [ 2424, 4643 ], $pos->getLogPosition() ); } /** @@ -198,20 +201,20 @@ class DatabaseMysqlBaseTest extends PHPUnit\Framework\TestCase { ], // MySQL GTID style [ - new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:23', $now ), - new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:24', $now ), + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-23', $now ), + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-24', $now ), true, false ], [ - new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:99', $now ), - new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:100', $now ), + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-99', $now ), + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ), true, false ], [ - new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:99', $now ), - new MySQLMasterPos( '1E11FA47-71CA-11E1-9E33-C80AA9429562:100', $now ), + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-99', $now ), + new MySQLMasterPos( '1E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ), false, false ], @@ -329,17 +332,17 @@ class DatabaseMysqlBaseTest extends PHPUnit\Framework\TestCase { ], [ new MySQLMasterPos( - '2E11FA47-71CA-11E1-9E33-C80AA9429562:5,' . - '3E11FA47-71CA-11E1-9E33-C80AA9429562:99,' . - '7E11FA47-71CA-11E1-9E33-C80AA9429562:30', + '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-5,' . + '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99,' . + '7E11FA47-71CA-11E1-9E33-C80AA9429562:1-30', 1 ), new MySQLMasterPos( - '1E11FA47-71CA-11E1-9E33-C80AA9429562:100,' . - '3E11FA47-71CA-11E1-9E33-C80AA9429562:66', + '1E11FA47-71CA-11E1-9E33-C80AA9429562:30-100,' . + '3E11FA47-71CA-11E1-9E33-C80AA9429562:30-66', 1 ), - [ '3E11FA47-71CA-11E1-9E33-C80AA9429562:99' ] + [ '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99' ] ] ]; } @@ -398,6 +401,160 @@ class DatabaseMysqlBaseTest extends PHPUnit\Framework\TestCase { ]; } + /** + * @dataProvider provideGtidData + * @covers Wikimedia\Rdbms\MySQLMasterPos + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getReplicaPos + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getMasterPos + */ + public function testServerGtidTable( $gtable, $rBLtable, $mBLtable, $rGTIDs, $mGTIDs ) { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ + 'useGTIDs', + 'getServerGTIDs', + 'getServerRoleStatus', + 'getServerId', + 'getServerUUID' + ] ) + ->getMock(); + + $db->method( 'useGTIDs' )->willReturn( true ); + $db->method( 'getServerGTIDs' )->willReturn( $gtable ); + $db->method( 'getServerRoleStatus' )->willReturnCallback( + function ( $role ) use ( $rBLtable, $mBLtable ) { + if ( $role === 'SLAVE' ) { + return $rBLtable; + } elseif ( $role === 'MASTER' ) { + return $mBLtable; + } + + return null; + } + ); + $db->method( 'getServerId' )->willReturn( 1 ); + $db->method( 'getServerUUID' )->willReturn( '2E11FA47-71CA-11E1-9E33-C80AA9429562' ); + + if ( is_array( $rGTIDs ) ) { + $this->assertEquals( $rGTIDs, $db->getReplicaPos()->getGTIDs() ); + } else { + $this->assertEquals( false, $db->getReplicaPos() ); + } + if ( is_array( $mGTIDs ) ) { + $this->assertEquals( $mGTIDs, $db->getMasterPos()->getGTIDs() ); + } else { + $this->assertEquals( false, $db->getMasterPos() ); + } + } + + public static function provideGtidData() { + return [ + // MariaDB + [ + [ + 'gtid_domain_id' => 100, + 'gtid_current_pos' => '100-13-77', + 'gtid_binlog_pos' => '100-13-77', + 'gtid_slave_pos' => null // master + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [ + 'File' => 'host.1600', + 'Position' => '77' + ], + [], + [ '100' => '100-13-77' ] + ], + [ + [ + 'gtid_domain_id' => 100, + 'gtid_current_pos' => '100-13-77', + 'gtid_binlog_pos' => '100-13-77', + 'gtid_slave_pos' => '100-13-77' // replica + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], + [ '100' => '100-13-77' ], + [ '100' => '100-13-77' ] + ], + [ + [ + 'gtid_current_pos' => '100-13-77', + 'gtid_binlog_pos' => '100-13-77', + 'gtid_slave_pos' => '100-13-77' // replica + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], + [ '100' => '100-13-77' ], + [ '100' => '100-13-77' ] + ], + // MySQL + [ + [ + 'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], // only a replica + [ '2E11FA47-71CA-11E1-9E33-C80AA9429562' + => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ], + // replica/master use same var + [ '2E11FA47-71CA-11E1-9E33-C80AA9429562' + => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ], + ], + [ + [ + 'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-49,' . + '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], // only a replica + [ '2E11FA47-71CA-11E1-9E33-C80AA9429562' + => '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ], + // replica/master use same var + [ '2E11FA47-71CA-11E1-9E33-C80AA9429562' + => '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ], + ], + [ + [ + 'gtid_executed' => null, // not enabled? + 'gtid_binlog_pos' => null + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], // only a replica + [], // binlog fallback + false + ], + [ + [ + 'gtid_executed' => null, // not enabled? + 'gtid_binlog_pos' => null + ], + [], // no replication + [], // no replication + false, + false + ] + ]; + } + /** * @covers Wikimedia\Rdbms\MySQLMasterPos */ diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php index 665e953e9d..3756da2613 100644 --- a/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php @@ -6,6 +6,7 @@ use Wikimedia\Rdbms\Database; use Wikimedia\TestingAccessWrapper; use Wikimedia\Rdbms\DBTransactionStateError; use Wikimedia\Rdbms\DBUnexpectedError; +use Wikimedia\Rdbms\DBTransactionError; /** * Test the parts of the Database abstract class that deal @@ -1402,22 +1403,33 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { // phpcs:ignore Generic.Files.LineLength $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); - $this->database->doAtomicSection( __METHOD__, function () { - } ); + $noOpCallack = function () { + }; + + $this->database->doAtomicSection( __METHOD__, $noOpCallack, IDatabase::ATOMIC_CANCELABLE ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->doAtomicSection( __METHOD__, $noOpCallack ); $this->assertLastSql( 'BEGIN; COMMIT' ); $this->database->begin( __METHOD__ ); - $this->database->doAtomicSection( __METHOD__, function () { - } ); + $this->database->doAtomicSection( __METHOD__, $noOpCallack, IDatabase::ATOMIC_CANCELABLE ); $this->database->rollback( __METHOD__ ); // phpcs:ignore Generic.Files.LineLength $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK' ); $this->database->begin( __METHOD__ ); try { - $this->database->doAtomicSection( __METHOD__, function () { - throw new RuntimeException( 'Test exception' ); - } ); + $this->database->doAtomicSection( + __METHOD__, + function () { + $this->database->startAtomic( 'inner_func1' ); + $this->database->startAtomic( 'inner_func2' ); + + throw new RuntimeException( 'Test exception' ); + }, + IDatabase::ATOMIC_CANCELABLE + ); $this->fail( 'Expected exception not thrown' ); } catch ( RuntimeException $ex ) { $this->assertSame( 'Test exception', $ex->getMessage() ); @@ -1425,6 +1437,21 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->database->commit( __METHOD__ ); // phpcs:ignore Generic.Files.LineLength $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $this->database->begin( __METHOD__ ); + try { + $this->database->doAtomicSection( + __METHOD__, + function () { + throw new RuntimeException( 'Test exception' ); + } + ); + $this->fail( 'Test exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Test exception', $ex->getMessage() ); + } + $this->database->rollback( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); } public static function provideAtomicSectionMethodsForErrors() { @@ -1445,7 +1472,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->fail( 'Expected exception not thrown' ); } catch ( DBUnexpectedError $ex ) { $this->assertSame( - 'No atomic transaction is open (got ' . __METHOD__ . ').', + 'No atomic section is open (got ' . __METHOD__ . ').', $ex->getMessage() ); } @@ -1463,7 +1490,8 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->fail( 'Expected exception not thrown' ); } catch ( DBUnexpectedError $ex ) { $this->assertSame( - 'Invalid atomic section ended (got ' . __METHOD__ . ').', + 'Invalid atomic section ended (got ' . __METHOD__ . ' but expected ' . + __METHOD__ . 'X' . ').', $ex->getMessage() ); } @@ -1476,10 +1504,11 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->database->startAtomic( __METHOD__ ); try { $this->database->cancelAtomic( __METHOD__ ); + $this->database->select( 'test', '1', [], __METHOD__ ); $this->fail( 'Expected exception not thrown' ); - } catch ( DBUnexpectedError $ex ) { + } catch ( DBTransactionError $ex ) { $this->assertSame( - 'Uncancelable atomic section canceled (got ' . __METHOD__ . ').', + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', $ex->getMessage() ); } @@ -1687,4 +1716,42 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase { $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' ); $this->assertEquals( 0, $this->database->trxLevel() ); } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose3() { + try { + $this->database->setFlag( IDatabase::DBO_TRX ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->assertEquals( 1, $this->database->trxLevel() ); + $this->database->close(); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'Wikimedia\Rdbms\Database::close: ' . + 'mass commit/rollback of peer transaction required (DBO_TRX set).', + $ex->getMessage() + ); + } + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose4() { + $this->database->setFlag( IDatabase::DBO_TRX ); + $this->database->query( 'SELECT 1', __METHOD__ ); + $this->assertEquals( 1, $this->database->trxLevel() ); + $this->database->close(); + $this->database->clearFlag( IDatabase::DBO_TRX ); + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; SELECT 1; COMMIT' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } } diff --git a/tests/phpunit/includes/registration/VersionCheckerTest.php b/tests/phpunit/includes/registration/VersionCheckerTest.php index 929ff0fa62..5dc7a969cb 100644 --- a/tests/phpunit/includes/registration/VersionCheckerTest.php +++ b/tests/phpunit/includes/registration/VersionCheckerTest.php @@ -6,6 +6,7 @@ class VersionCheckerTest extends PHPUnit\Framework\TestCase { use MediaWikiCoversValidator; + use PHPUnit4And6Compat; /** * @dataProvider provideCheck diff --git a/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php b/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php index 51182180a9..aeaa1aee9e 100644 --- a/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php +++ b/tests/phpunit/includes/specialpage/ChangesListSpecialPageTest.php @@ -347,7 +347,7 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase $user = $this->getTestSysop()->getUser(); $this->assertConditions( [ # expected - 'rc_patrolled = 0', + 'rc_patrolled' => 0, ], [ 'hidepatrolled' => 1, @@ -361,7 +361,7 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase $user = $this->getTestSysop()->getUser(); $this->assertConditions( [ # expected - 'rc_patrolled != 0', + 'rc_patrolled' => [ 1, 2 ], ], [ 'hideunpatrolled' => 1, @@ -371,6 +371,30 @@ class ChangesListSpecialPageTest extends AbstractChangesListSpecialPageTestCase ); } + public function testRcReviewStatusFilter() { + $user = $this->getTestSysop()->getUser(); + $this->assertConditions( + [ #expected + 'rc_patrolled' => 1, + ], + [ + 'reviewStatus' => 'manual' + ], + "rc conditions: reviewStatus=manual", + $user + ); + $this->assertConditions( + [ #expected + 'rc_patrolled' => [ 0, 2 ], + ], + [ + 'reviewStatus' => 'unpatrolled;auto' + ], + "rc conditions: reviewStatus=unpatrolled;auto", + $user + ); + } + public function testRcHideminorFilter() { $this->assertConditions( [ # expected