Merge "resourceloader: Remove use of $.isPlainObject() from mw.Map#set()"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Thu, 10 May 2018 20:23:43 +0000 (20:23 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Thu, 10 May 2018 20:23:43 +0000 (20:23 +0000)
30 files changed:
autoload.php
composer.json
includes/api/i18n/he.json
includes/libs/rdbms/lbfactory/LBFactory.php
includes/libs/rdbms/loadbalancer/ILoadBalancer.php
includes/libs/rdbms/loadbalancer/LoadBalancer.php
includes/logging/LogEventsList.php
includes/logging/LogPager.php
languages/i18n/be-tarask.json
languages/i18n/be.json
languages/i18n/hu.json
languages/i18n/ig.json
languages/i18n/inh.json
languages/i18n/jv.json
languages/i18n/lb.json
languages/i18n/pt-br.json
languages/i18n/pt.json
languages/i18n/ru.json
languages/i18n/szl.json
maintenance/categoryChangesAsRdf.php [new file with mode: 0644]
package.json
resources/src/mediawiki.skinning/content.parsoid.less
tests/phpunit/data/categoriesrdf/change.sparql [new file with mode: 0644]
tests/phpunit/data/categoriesrdf/delete.sparql [new file with mode: 0644]
tests/phpunit/data/categoriesrdf/move.sparql [new file with mode: 0644]
tests/phpunit/data/categoriesrdf/new.sparql [new file with mode: 0644]
tests/phpunit/data/categoriesrdf/restore.sparql [new file with mode: 0644]
tests/phpunit/data/categoriesrdf/updatets.txt [new file with mode: 0644]
tests/phpunit/includes/db/LBFactoryTest.php
tests/phpunit/maintenance/categoryChangesRdfTest.php [new file with mode: 0644]

index ec0d59f..27ff848 100644 (file)
@@ -225,6 +225,7 @@ $wgAutoloadLocalClasses = [
        'CapsCleanup' => __DIR__ . '/maintenance/cleanupCaps.php',
        'CategoriesRdf' => __DIR__ . '/includes/CategoriesRdf.php',
        'Category' => __DIR__ . '/includes/Category.php',
+       'CategoryChangesAsRdf' => __DIR__ . '/maintenance/categoryChangesAsRdf.php',
        'CategoryFinder' => __DIR__ . '/includes/CategoryFinder.php',
        'CategoryMembershipChange' => __DIR__ . '/includes/changes/CategoryMembershipChange.php',
        'CategoryMembershipChangeJob' => __DIR__ . '/includes/jobqueue/jobs/CategoryMembershipChangeJob.php',
index 8de4025..833e3bf 100644 (file)
@@ -1,6 +1,7 @@
 {
        "name": "mediawiki/core",
        "description": "Free software wiki application developed by the Wikimedia Foundation and others",
+       "type": "mediawiki-core",
        "keywords": ["mediawiki", "wiki"],
        "homepage": "https://www.mediawiki.org/",
        "authors": [
index 097d510..4e59ef4 100644 (file)
        "apihelp-query+allimages-param-sha1base36": "גיבוב SHA1 של התמונה בבסיס 36 (הבסיס בו נעשה שימוש במדיה־ויקי).",
        "apihelp-query+allimages-param-user": "להחזיר רק קבצים שהועלו על־ידי המשתמש הזה. יכול לשמש רק עם $1sort=timestamp. לא יכול לשמש יחד עם $1filterbots.",
        "apihelp-query+allimages-param-filterbots": "איך לסנן קבצים שמעלים בוטים. יכול לשמש רק עם $1sort=timestamp. לא יכול לשמש יחד עם $1user.",
-       "apihelp-query+allimages-param-mime": "×\90×\99×\9c×\95 ×¡×\95×\92×\99 MIME ×\9c×\97×\91פש, ×\9c×\9eש×\9c <kbd>image/jpeg</kbd>.",
+       "apihelp-query+allimages-param-mime": "אילו סוגי MIME לחפש, למשל <kbd>image/jpeg</kbd>.",
        "apihelp-query+allimages-param-limit": "כמה תמונות להחזיר בסך הכול.",
        "apihelp-query+allimages-example-B": "הצגת רשימה של קבצים שמתחילים באות <kbd>B</kbd>.",
        "apihelp-query+allimages-example-recent": "הצגת רשימת קבצים שהועלו לאחרונה, דומה ל־[[Special:NewFiles]].",
        "apihelp-query+categories-paramvalue-prop-hidden": "תיוג קטגוריות שהוסתרו באמצעות <code>_&#95;HIDDENCAT_&#95;</code>.",
        "apihelp-query+categories-param-show": "איזה סוג של קטגוריות להציג.",
        "apihelp-query+categories-param-limit": "כמה קטגוריות להחזיר.",
-       "apihelp-query+categories-param-categories": "×\9cרש×\95×\9d ×¨×§ ×\90ת ×\94ק×\98×\92×\95ר×\99×\95ת ×\94×\90×\9c×\95. ×©×\99×\9e×\95ש×\99 ×\9c×\91×\93×\99ק×\94 ×¢ם דף מסוים נמצא בקטגוריה מסוימת.",
+       "apihelp-query+categories-param-categories": "×\9cרש×\95×\9d ×¨×§ ×\90ת ×\94ק×\98×\92×\95ר×\99×\95ת ×\94×\90×\9c×\95. ×©×\99×\9e×\95ש×\99 ×\9c×\91×\93×\99ק×\94 ×\90ם דף מסוים נמצא בקטגוריה מסוימת.",
        "apihelp-query+categories-param-dir": "באיזה כיוון לרשום.",
        "apihelp-query+categories-example-simple": "קבלת רשימת קטגוריות שהם <kbd>Albert Einstein</kbd> שייך אליהן.",
        "apihelp-query+categories-example-generator": "קבלת מידע על כל הקטגוריות שמשמשות בדף <kbd>Albert Einstein</kbd>.",
        "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "משמש את Special:Upload כדי לקבל מידע על קובץ קיים. לא נועד לשימוש מחוץ לליבת MediaWiki.",
        "apihelp-query+imageinfo-paramvalue-prop-badfile": "מוסיף האם הקובץ נמצא ב־[[MediaWiki:Bad image list]]",
        "apihelp-query+imageinfo-param-limit": "כמה גרסאות של קובץ לכל קובץ.",
-       "apihelp-query+imageinfo-param-start": "×\9e×\90×\99×\96 ×\97×\95ת×\9dÖ¾×\96×\9e×\9f ×\9c×\94ת×\97×\99×\9c רשימה.",
+       "apihelp-query+imageinfo-param-start": "×\9e×\90×\99×\9c×\95 ×ª×\90ר×\99×\9a ×\95שע×\94 ×\9c×\94ת×\97×\99×\9c ×\90ת ×\94רשימה.",
        "apihelp-query+imageinfo-param-end": "באיזה חותם־זמן לסיים את הרשימה.",
        "apihelp-query+imageinfo-param-urlwidth": "אם מוגדר $2prop=url, יוחזר URL לתמונה שגודלה הותאם לרוחב הזה.\nמסיבות של ביצועים, אם האפשרות הזאת משמשת, לא יוחזרו יותר מ־$1 תמונות.",
        "apihelp-query+imageinfo-param-urlheight": "דומה ל־$1urlwidth.",
index 38c7a5c..097775a 100644 (file)
@@ -95,6 +95,8 @@ abstract class LBFactory implements ILBFactory {
        const ROUND_BEGINNING = 'within-begin';
        const ROUND_COMMITTING = 'within-commit';
        const ROUND_ROLLING_BACK = 'within-rollback';
+       const ROUND_COMMIT_CALLBACKS = 'within-commit-callbacks';
+       const ROUND_ROLLBACK_CALLBACKS = 'within-rollback-callbacks';
 
        private static $loggerFields =
                [ 'replLogger', 'connLogger', 'queryLogger', 'perfLogger' ];
@@ -261,6 +263,7 @@ abstract class LBFactory implements ILBFactory {
                // Actually perform the commit on all master DB connections and revert DBO_TRX
                $this->forEachLBCallMethod( 'commitMasterChanges', [ $fname ] );
                // Run all post-commit callbacks in a separate step
+               $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS;
                $e = $this->executePostTransactionCallbacks();
                $this->trxRoundStage = self::ROUND_CURSORY;
                // Throw any last post-commit callback error
@@ -275,6 +278,7 @@ abstract class LBFactory implements ILBFactory {
                // Actually perform the rollback on all master DB connections and revert DBO_TRX
                $this->forEachLBCallMethod( 'rollbackMasterChanges', [ $fname ] );
                // Run all post-commit callbacks in a separate step
+               $this->trxRoundStage = self::ROUND_ROLLBACK_CALLBACKS;
                $this->executePostTransactionCallbacks();
                $this->trxRoundStage = self::ROUND_CURSORY;
        }
@@ -551,6 +555,14 @@ abstract class LBFactory implements ILBFactory {
         * @return array
         */
        final protected function baseLoadBalancerParams() {
+               if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) {
+                       $initStage = ILoadBalancer::STAGE_POSTCOMMIT_CALLBACKS;
+               } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) {
+                       $initStage = ILoadBalancer::STAGE_POSTROLLBACK_CALLBACKS;
+               } else {
+                       $initStage = null;
+               }
+
                return [
                        'localDomain' => $this->localDomain,
                        'readOnlyReason' => $this->readOnlyReason,
@@ -570,7 +582,8 @@ abstract class LBFactory implements ILBFactory {
                                // Defer ChronologyProtector construction in case setRequestInfo() ends up
                                // being called later (but before the first connection attempt) (T192611)
                                $this->getChronologyProtector()->initLB( $lb );
-                       }
+                       },
+                       'roundStage' => $initStage
                ];
        }
 
index 850f9af..81ce4ba 100644 (file)
@@ -89,6 +89,11 @@ interface ILoadBalancer {
        /** @var int Alias for CONN_TRX_AUTOCOMMIT for b/c; deprecated since 1.31 */
        const CONN_TRX_AUTO = 1;
 
+       /** @var string Manager of ILoadBalancer instances is running post-commit callbacks */
+       const STAGE_POSTCOMMIT_CALLBACKS = 'stage-postcommit-callbacks';
+       /** @var string Manager of ILoadBalancer instances is running post-rollback callbacks */
+       const STAGE_POSTROLLBACK_CALLBACKS = 'stage-postrollback-callbacks';
+
        /**
         * Construct a manager of IDatabase connection objects
         *
@@ -112,6 +117,7 @@ interface ILoadBalancer {
         *  - perfLogger: PSR-3 logger instance. [optional]
         *  - errorLogger : Callback that takes an Exception and logs it. [optional]
         *  - deprecationLogger: Callback to log a deprecation warning. [optional]
+        *  - roundStage: STAGE_POSTCOMMIT_* class constant; for internal use [optional]
         * @throws InvalidArgumentException
         */
        public function __construct( array $params );
index 405ed14..360be42 100644 (file)
@@ -261,6 +261,14 @@ class LoadBalancer implements ILoadBalancer {
                if ( isset( $params['chronologyCallback'] ) ) {
                        $this->chronologyCallback = $params['chronologyCallback'];
                }
+
+               if ( isset( $params['roundStage'] ) ) {
+                       if ( $params['roundStage'] === self::STAGE_POSTCOMMIT_CALLBACKS ) {
+                               $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS;
+                       } elseif ( $params['roundStage'] === self::STAGE_POSTROLLBACK_CALLBACKS ) {
+                               $this->trxRoundStage = self::ROUND_ROLLBACK_CALLBACKS;
+                       }
+               }
        }
 
        /**
index 93a81cf..9e4a630 100644 (file)
@@ -97,7 +97,7 @@ class LogEventsList extends ContextSource {
         * @param array|string $types
         * @param string $user
         * @param string $page
-        * @param string $pattern
+        * @param bool $pattern
         * @param int|string $year Use 0 to start with no year preselected.
         * @param int|string $month A month in the 1..12 range. Use 0 to start with no month
         *  preselected.
@@ -105,7 +105,7 @@ class LogEventsList extends ContextSource {
         * @param string $tagFilter Tag to select by default
         * @param string $action
         */
-       public function showOptions( $types = [], $user = '', $page = '', $pattern = '', $year = 0,
+       public function showOptions( $types = [], $user = '', $page = '', $pattern = false, $year = 0,
                $month = 0, $filter = null, $tagFilter = '', $action = null
        ) {
                global $wgScript, $wgMiserMode;
@@ -289,7 +289,7 @@ class LogEventsList extends ContextSource {
        }
 
        /**
-        * @param string $pattern
+        * @param bool $pattern
         * @return string Checkbox
         */
        private function getTitlePattern( $pattern ) {
index c047e96..84653b1 100644 (file)
@@ -36,8 +36,8 @@ class LogPager extends ReverseChronologicalPager {
        /** @var string|Title Events limited to those about Title when set */
        private $title = '';
 
-       /** @var string */
-       private $pattern = '';
+       /** @var bool */
+       private $pattern = false;
 
        /** @var string */
        private $typeCGI = '';
@@ -59,7 +59,7 @@ class LogPager extends ReverseChronologicalPager {
         * @param string|array $types Log types to show
         * @param string $performer The user who made the log entries
         * @param string|Title $title The page title the log entries are for
-        * @param string $pattern Do a prefix search rather than an exact title match
+        * @param bool $pattern Do a prefix search rather than an exact title match
         * @param array $conds Extra conditions for the query
         * @param int|bool $year The year to start from. Default: false
         * @param int|bool $month The month to start from. Default: false
@@ -68,7 +68,7 @@ class LogPager extends ReverseChronologicalPager {
         * @param int $logId Log entry ID, to limit to a single log entry.
         */
        public function __construct( $list, $types = [], $performer = '', $title = '',
-               $pattern = '', $conds = [], $year = false, $month = false, $tagFilter = '',
+               $pattern = false, $conds = [], $year = false, $month = false, $tagFilter = '',
                $action = '', $logId = false
        ) {
                parent::__construct( $list->getContext() );
@@ -194,7 +194,7 @@ class LogPager extends ReverseChronologicalPager {
         * (For the block and rights logs, this is a user page.)
         *
         * @param string|Title $page Title name
-        * @param string $pattern
+        * @param bool $pattern
         * @return void
         */
        private function limitTitle( $page, $pattern ) {
@@ -398,6 +398,9 @@ class LogPager extends ReverseChronologicalPager {
                return $this->title;
        }
 
+       /**
+        * @return bool
+        */
        public function getPattern() {
                return $this->pattern;
        }
index df3cd07..96dea99 100644 (file)
        "botpasswords-restriction-failed": "Уваход ня выкананы праз абмежаваньні на пароль робата",
        "botpasswords-invalid-name": "Пададзенае імя ўдзельніка ня ўтрымлівае падзяляльнік для паролю робата («$1»).",
        "botpasswords-not-exist": "Удзельнік «$1» ня мае паролю для робата з назвай «$2».",
+       "botpasswords-needs-reset": "Пароль для робата зь імем «$2» {{GENDER:$1|удзельніка|удзельніцы}} «$1» мусіць быць скінуты.",
        "resetpass_forbidden": "Пароль ня можа быць зьменены",
        "resetpass_forbidden-reason": "Паролі ня могуць быць зьмененыя: $1",
        "resetpass-no-info": "Для непасрэднага доступу да гэтай старонкі Вам неабходна ўвайсьці ў сыстэму.",
        "upload": "Загрузіць файл",
        "uploadbtn": "Загрузіць файл",
        "reuploaddesc": "Скасаваць загрузку і вярнуцца да формы загрузкі",
-       "upload-tryagain": "Даслаць зьмененае апісаньне файла",
+       "upload-tryagain": "Даслаць зьмененае апісаньне файлу",
        "upload-tryagain-nostash": "Даслаць паўторна загружаны файл і зьмененае апісаньне",
        "uploadnologin": "Вы не ўвайшлі ў сыстэму",
        "uploadnologintext": "Вам трэба $1, каб загружаць файлы.",
index 0333b61..c7d9bf6 100644 (file)
        "recentchangesdays-max": "(найбольш $1 {{PLURAL:$1|дзень|дзён}})",
        "recentchangescount": "Перадвызначаная колькасць правак для паказу:",
        "prefs-help-recentchangescount": "Максімальная колькасць: 1000",
-       "prefs-help-watchlist-token2": "Гэта сакрэтны ключ да сеціўнай стужкі з Вашага спіса назірання.\nКожны, хто ведае гэты ключ, будзе мець магчымасць чытаць Ваш спіс назірання, таму не дзяліцеся ім.\nКалі трэба, можна [[Special:ResetTokens|скінуць яго]].",
+       "prefs-help-watchlist-token2": "Гэта сакрэтны ключ да сеціўнай стужкі з Вашага спісу назірання.\nКожны, хто ведае гэты ключ, будзе мець магчымасць чытаць Ваш спіс назірання, таму не дзяліцеся ім.\nКалі трэба, можна [[Special:ResetTokens|скінуць яго]].",
        "savedprefs": "Настройкі замацаваныя.",
        "savedrights": "Групы {{GENDER:$1|ўдзельніка|ўдзельніцы}} $1 захаваныя.",
        "timezonelegend": "Часавы пояс:",
        "uploaded-event-handler-on-svg": "Устаноўка атрыбутаў апрацоўшчыка падзей <code>$1=\"$2\"</code> у SVG файле не дазваляецца.",
        "uploaded-href-attribute-svg": "у SVG файлах атрыбутам href дазволены толькі мэты віду http:// або https://, знойдзена <code>&lt;$1 $2=\"$3\"&gt;</code>.",
        "uploaded-href-unsafe-target-svg": "У ўкладзеным SVG файле знойдзена спасылка на небяспечныя звесткі: URI мэты <code>&lt;$1 $2=\"$3\"&gt;</code>.",
-       "uploaded-animate-svg": "У ўкладзеным SVG файле знойдзены тэг \"animate\", здольны змяніць спасылку з дапамогай атрыбута \"from\" <code>&lt;$1 $2=\"$3\"&gt;</code>.",
+       "uploaded-animate-svg": "Ð\92а ўкладзеным SVG файле знойдзены тэг \"animate\", здольны змяніць спасылку з дапамогай атрыбута \"from\" <code>&lt;$1 $2=\"$3\"&gt;</code>.",
        "uploaded-setting-event-handler-svg": "Устаноўка атрыбутаў апрацоўкі падзей заблакавана, у ўкладзеным SVG-файле знойдзены код <code>&lt;$1 $2=\"$3\"&gt;</code>.",
        "uploaded-setting-href-svg": "Выкарыстанне тэга \"set\" для дадання атрыбута \"href\" у бацькоўскі элемент заблакавана.",
        "uploaded-wrong-setting-svg": "Ужыванне тэга \"set\" для задання ў якасці мэты аддаленага адраса/звестак/сцэнарыя для любога атрыбута заблакавана. У ўкладзеным SVG-файле знойдзены <code>&lt;set to=\"$1\"&gt;</code>.",
        "tooltip-ca-undelete": "Аднавіць праўкі, зробленыя на гэтай старонцы перад тым, як яна была сцёрта",
        "tooltip-ca-move": "Перанесці гэтую старонку пад іншую назву",
        "tooltip-ca-watch": "Дадаць гэтую старонку да свайго спісу назіраных старонак",
-       "tooltip-ca-unwatch": "Выняць гэту старонку з вашага спіса назірання",
+       "tooltip-ca-unwatch": "Выняць гэтую старонку з Вашага спісу назірання",
        "tooltip-search": "Знайсці ў {{SITENAME}}",
        "tooltip-search-go": "Перайсці да старонкі з дакладна такой назвай, калі такая наогул існуе",
        "tooltip-search-fulltext": "Знайсці гэты тэкст у тэкстах старонак",
        "confirm-watch-button": "ОК",
        "confirm-watch-top": "Дабавіць старонку ў спіс назірання",
        "confirm-unwatch-button": "ОК",
-       "confirm-unwatch-top": "Ð\92Ñ\8bнÑ\8fÑ\86Ñ\8c Ð³Ñ\8dÑ\82Ñ\83 Ñ\81Ñ\82аÑ\80онкÑ\83 Ð· Ð²Ð°Ñ\88ага Ñ\81пÑ\96Ñ\81а назірання?",
+       "confirm-unwatch-top": "Ð\92Ñ\8bнÑ\8fÑ\86Ñ\8c Ð³Ñ\8dÑ\82Ñ\83 Ñ\81Ñ\82аÑ\80онкÑ\83 Ð· Ð\92аÑ\88ага Ñ\81пÑ\96Ñ\81Ñ\83 назірання?",
        "confirm-rollback-button": "Добра",
        "confirm-rollback-top": "Адкаціць праўкі гэтай старонкі?",
        "quotation-marks": "«$1»",
        "autosumm-replace": "Замена старонкі на '$1'",
        "autoredircomment": "Перасылае да [[$1]]",
        "autosumm-removed-redirect": "Выдаленае перенаправление на [[$1]]",
+       "autosumm-changed-redirect-target": "Мэта перасылкі зменена з [[$1]] на [[$2]]",
        "autosumm-new": "Новая старонка: '$1'",
        "autosumm-newblank": "Створана пустая старонка",
        "size-bytes": "$1 {{PLURAL:$1|байт|байты|байтаў}}",
        "watchlistedit-normal-legend": "Выдаленне складнікаў са спісу назірання",
        "watchlistedit-normal-explain": "Назвы старонак з ліку назіраных паказаныя ніжэй. Каб нешта выдаліць, адзначце клетку побач з адпаведным радком, пасля чаго націсніце \"Выняць складнікі\". Таксама можна правіць гэты спіс непасрэдна, [[Special:EditWatchlist/raw|без афармлення]].",
        "watchlistedit-normal-submit": "Выняць складнікі",
-       "watchlistedit-normal-done": "Ð\97 Ð²Ð°Ñ\88ага Ñ\81пÑ\96Ñ\81а назірання {{PLURAL:$1|быў выдалены|былі выдалены|было выдалена}} $1 {{PLURAL:$1|складнік|складнікі|складнікаў}}:",
+       "watchlistedit-normal-done": "Ð\97 Ð\92аÑ\88ага Ñ\81пÑ\96Ñ\81Ñ\83 назірання {{PLURAL:$1|быў выдалены|былі выдалены|было выдалена}} $1 {{PLURAL:$1|складнік|складнікі|складнікаў}}:",
        "watchlistedit-raw-title": "Нефарматаваны спіс назірання",
        "watchlistedit-raw-legend": "Правіць нефарматаваны спіс назірання",
        "watchlistedit-raw-explain": "Назвы старонак з ліку назіраных паказаныя ніжэй, без афармлення, адна назва на адзін радок; такім чынам, спіс можна правіць як звычайны тэкст. Па сканчэнні націсніце \"{{int:Watchlistedit-raw-submit}}\". Таксама гэта можна зрабіць праз [[Special:EditWatchlist|стандартны інтэрфейс]].",
        "logentry-delete-delete": "$1 {{GENDER:$2|выдаліў|выдаліла}} старонку $3",
        "logentry-delete-delete_redir": "$1 {{GENDER:$2|выдаліў|выдаліла}} перанакіраванне $3 шляхам перазапісу",
        "logentry-delete-restore": "$1 {{GENDER:$2|аднавіў|аднавіла}} старонку $3 ($4)",
+       "restore-count-revisions": "{{PLURAL:$1|1 версія|$1 версіі|$1 версій}}",
        "logentry-delete-event": "$1 {{GENDER:$2|змяніў|змяніла}} бачнасць {{PLURAL:$5|запісу журнала|$5 запісаў журнала}} $3: $4",
        "logentry-delete-revision": "$1 {{GENDER:$2|змяніў|змяніла}} бачнасць {{PLURAL:$5|версіі|$5 версій|$5 версій}} старонкі $3: $4",
        "logentry-delete-event-legacy": "$1 {{GENDER:$2|змяніў|змяніла}} бачнасць запісаў журнала $3",
index f82859f..53241d4 100644 (file)
        "longpageerror": "'''HIBA: Az általad beküldött szöveg {{PLURAL:$1|egy kilobájt|$1 kilobájt}} hosszú, ami több az engedélyezett {{PLURAL:$2|egy kilobájtnál|$2 kilobájtnál}}.\nA szerkesztést nem lehet elmenteni.'''",
        "readonlywarning": "<strong>FIGYELMEZTETÉS: A wiki adatbázisát karbantartás miatt zárolták, ezért most nem fogod tudni elmenteni a szerkesztéseidet!</strong>\nA lap szövegét másold egy szövegfájlba, amit később felhasználhatsz!\n\nAz adatbázist lezáró rendszeradminisztrátor az alábbi magyarázatot adta: $1",
        "protectedpagewarning": "<strong>Figyelem: Ez a lap védett, így csak adminisztrátori jogosultságokkal rendelkező szerkesztők módosíthatják.</strong>\nA legutolsó ide vonatkozó naplóbejegyzés alább látható:",
-       "semiprotectedpagewarning": "'''Megjegyzés:''' ez a lap védett, így regisztrálatlan vagy újonnan regisztrált szerkesztők nem módosíthatják.",
+       "semiprotectedpagewarning": "<strong>Megjegyzés:</strong> ez a lap védett, így regisztrálatlan vagy újonnan regisztrált szerkesztők nem módosíthatják.\nA lapra vonatkozó utolsó naplóbejegyzés alább látható:",
        "cascadeprotectedwarning": "<strong>Figyelem:</strong> ez a lap le van zárva, csak [[Special:ListGroupRights|megfelelő jogosultságú]] felhasználók szerkeszthetik, mert a következő kaszkádvédelemmel ellátott {{PLURAL:$1|lapon|lapokon}} be van illesztve:",
        "titleprotectedwarning": "'''Figyelem: Ez a lap le van védve, így csak a [[Special:ListGroupRights|megfelelő jogosultságokkal]] rendelkező szerkesztők hozhatják létre.'''\nA legutolsó ide vonatkozó naplóbejegyzés alább látható:",
        "templatesused": "A lapon használt {{PLURAL:$1|sablon|sablonok}}:",
index d652bd1..9674bdc 100644 (file)
        "logout": "Fwuör",
        "userlogout": "Fwuör",
        "notloggedin": "I bátà bò",
+       "userlogin-joinproject": "Bàkọ {{SITENAME}}",
        "createaccount": "Ké otụ buwa",
        "createaccountmail": "na e-mail",
+       "createacct-benefit-heading": "{{SITENAME}} sì nà aka ndị dị kà gị.",
        "createacct-benefit-body1": "{{PLURAL:$1|ḿmezi}}",
        "badretype": "Mkpurụ okwu ejị a gafẹ é jëghị.",
        "userexists": "Áhè ọ'bànifé tírí di na áká onye ozor.\nBíkó nwèré áhà nke ozor.",
        "pageinfo-header-edits": "Mèzí ịta",
        "pageinfo-length": "Ogologo ihü (na baitusu)",
        "pageinfo-article-id": "ID Ihü",
+       "pageinfo-toolboxlink": "Nkàta ihu",
        "pageinfo-redirectsto-info": "ọ́márí",
        "pageinfo-contentpage-yes": "Eeh",
        "pageinfo-protect-cascading-yes": "Eeh",
index 80cf726..110c031 100644 (file)
        "userpage-userdoesnotexist-view": "«$1» яха дагара йоазув долаш дац.",
        "clearyourcache": "<strong>Теркал де.</strong> Хетаргахьа, оагIув дIаязъяь яьлча шоай браузера кэш IоцIанъе езаргья шун, даь хувцамаш гургдолаш.\n* <strong>Firefox / Safari:</strong> <em>Shift</em> яха лак тоIояь лоаттаеш инструментий цхьа дакъа тIа тоIае <em>Обновить</em> е <em>Ctrl-F5</em> тоIае е <em>Ctrl-R</em> (<em>⌘-R</em> Mac тIа)\n* <strong>Google Chrome:</strong> ТоIае <em>Ctrl-Shift-R</em> (<em>⌘-Shift-R</em> Mac тIа)\n* <strong>Internet Explorer:</strong> <em>Ctrl</em> яха лак тоIояь лоаттаеш, тоIае <em>Обновить</em> е <em>Ctrl-F5</em> тоIае\n* <strong>Opera:</strong> ДехьагIо <em>Menu → Настройки</em> (<em>Opera → Настройки</em> Mac тIа), тIаккха <em>Безопасность → Очистить историю посещений → Кэшированные изображения и файлы</em>",
        "note": "'''Белгалдоахар:'''",
-       "previewnote": "'''Теркам бе, ер хьалххе бIаргтохар мара бац.'''\nХьа хувцамаш хIанза а дIаяздаь дац!",
-       "continue-editing": "Хувцар кхы дIахо де",
+       "previewnote": "'''Теркам бе! Ер хьалххе бIаргтохар мара бац.'''\nХьа хувцамаш хIанзехьа кхы а дIаяздаь дац!",
+       "continue-editing": "Хувцам бергболаш дIахо дехьавáла",
        "editing": "Хувцам: $1",
-       "creating": "«$1» оагIув хьакхоллар",
-       "editingsection": "Хувцам: $1 (оагӀон дáкъа)",
+       "creating": "«$1» яха оагIув хьакхоллар",
+       "editingsection": "Хувцар: $1 (оагӀон дáкъа)",
        "editingcomment": "Хувцам: $1 (оагӀон керда дáкъа)",
        "editconflict": "Хувцама вIашдухьалъоттам: $1",
        "yourtext": "Хьа текст",
index 0d85f3b..f4aa034 100644 (file)
@@ -17,7 +17,8 @@
                        "아라",
                        "Macofe",
                        "Matma Rex",
-                       "Fitoschido"
+                       "Fitoschido",
+                       "Notanotheramy"
                ]
        },
        "tog-underline": "Garis ngisori pranala:",
        "savechanges": "Simpen owahan",
        "publishpage": "Babar kaca",
        "publishchanges": "Babar owahan",
+       "publishchanges-start": "Babar owahan...",
        "preview": "Pratuduh",
        "showpreview": "Deleng pratuduh",
        "showdiff": "Tuduhaké owahan",
index 8bea39a..22d4b62 100644 (file)
        "botpasswords-existing": "Aktuell Botpasswierder.",
        "botpasswords-createnew": "En neit Botpasswuert uleeën",
        "botpasswords-editexisting": "E Botpasswuert änneren",
+       "botpasswords-label-needsreset": "(Passwuert muss zréckgesat ginn)",
        "botpasswords-label-appid": "Numm vum Bot:",
        "botpasswords-label-create": "Uleeën",
        "botpasswords-label-update": "Aktualiséieren",
index 877184c..44ded2b 100644 (file)
        "botpasswords-existing": "Senhas de robôs existentes",
        "botpasswords-createnew": "Crie uma nova senha de robô",
        "botpasswords-editexisting": "Editar uma senha de robô existente",
+       "botpasswords-label-needsreset": "(senha precisa ser redefinida)",
        "botpasswords-label-appid": "Nome do robô:",
        "botpasswords-label-create": "Criar",
        "botpasswords-label-update": "Atualizar",
index 9a67d6f..7863040 100644 (file)
        "botpasswords-existing": "Palavras-passe de robô existentes",
        "botpasswords-createnew": "Criar uma nova palavra-passe para robô",
        "botpasswords-editexisting": "Editar uma palavra-passe de robô existente",
+       "botpasswords-label-needsreset": "(a palavra-passe precisa ser redefinida)",
        "botpasswords-label-appid": "Nome do robô:",
        "botpasswords-label-create": "Criar",
        "botpasswords-label-update": "Atualizar",
        "botpasswords-restriction-failed": "Restrições da palavra-passe de robô impedem esta autenticação.",
        "botpasswords-invalid-name": "O nome de utilizador especificado não contém o separador de palavra-passe de robô (\"$1\").",
        "botpasswords-not-exist": "O utilizador \"$1\" não tem uma palavra-passe para o robô chamado \"$2\".",
+       "botpasswords-needs-reset": "A palavra-passe de robô, para o robô de nome \"$2\" {{GENDER:$1|do utilizador|da utilizadora}} \"$1\" deve ser redefinida.",
        "resetpass_forbidden": "As palavras-passe não podem ser alteradas",
        "resetpass_forbidden-reason": "As palavras-passe não podem ser alteradas: $1",
        "resetpass-no-info": "Precisa de iniciar sessão para aceder diretamente a esta página.",
        "recentchangeslinked-feed": "Alterações relacionadas",
        "recentchangeslinked-toolbox": "Alterações relacionadas",
        "recentchangeslinked-title": "Alterações relacionadas com \"$1\"",
-       "recentchangeslinked-summary": "Introduza o nome de uma página para ver as mudanças a todas as páginas que contêm hiperligações para ela ou para as quais a página fornecida contém hiperligações (para ver as que pertencem a uma categoria, introduza Categoria:Nome da categoria). As mudanças às suas [[Special:Watchlist|páginas vigiadas]] aparecem a <strong>negrito</strong>.",
+       "recentchangeslinked-summary": "Introduza o nome de uma página para ver as mudanças a todas as páginas que contêm hiperligações para ela ou para as quais a página fornecida contém hiperligações (para ver as que pertencem a uma categoria, introduza {{ns:category}}:Nome da categoria). As mudanças às suas [[Special:Watchlist|páginas vigiadas]] aparecem a <strong>negrito</strong>.",
        "recentchangeslinked-page": "Nome da página:",
        "recentchangeslinked-to": "Inversamente, mostrar mudanças às páginas que contêm hiperligações para esta",
        "recentchanges-page-added-to-category": "[[:$1]] foi adicionada à categoria",
index 99d0548..e4efea8 100644 (file)
        "botpasswords-existing": "Существующие пароли бота",
        "botpasswords-createnew": "Создать новый пароль бота",
        "botpasswords-editexisting": "Редактировать существующий пароль бота",
+       "botpasswords-label-needsreset": "(пароль должен быть сброшен)",
        "botpasswords-label-appid": "Название бота:",
        "botpasswords-label-create": "Создать",
        "botpasswords-label-update": "Обновить",
        "botpasswords-restriction-failed": "Из-за ограничений, связанных с паролем бота, вход не произведён.",
        "botpasswords-invalid-name": "Указанное имя участника не содержит разделителя для пароля бота («$1»).",
        "botpasswords-not-exist": "У участника «$1» нет пароля для бота с названием «$2».",
+       "botpasswords-needs-reset": "Пароль для бота «$1» {{GENDER:$2|участника|участницы}} «$2» должен быть сброшен.",
        "resetpass_forbidden": "Пароль не может быть изменён",
        "resetpass_forbidden-reason": "Пароли не могут быть изменены: $1",
        "resetpass-no-info": "Чтобы обращаться непосредственно к этой странице, вам следует представиться системе.",
index d78fcd1..2a3b845 100644 (file)
        "welcomecreation-msg": "Uotwarli my sam lo Ćebje kůnto.\nPamjyntej coby posztalować [[Special:Preferences|preferencyji]]",
        "yourname": "Mjano użytkowńika:",
        "userlogin-yourname": "Mjano używocza",
-       "userlogin-yourname-ph": "Wszkryflej swoje mjano użytkowńika",
+       "userlogin-yourname-ph": "Wkludź swoje miano używacza",
        "createacct-another-username-ph": "Wszkryflej mjano użytkowńika",
        "yourpassword": "Hasło:",
        "userlogin-yourpassword": "Hasło",
-       "userlogin-yourpassword-ph": "Wszkryflej swoje hasło",
-       "createacct-yourpassword-ph": "Wszkryflej hasło",
+       "userlogin-yourpassword-ph": "Wkludź swoje hasło",
+       "createacct-yourpassword-ph": "Wkludź hasło",
        "yourpasswordagain": "Naszkryflej ausdruk zaś",
        "createacct-yourpasswordagain": "Potwjyrdź hasło",
-       "createacct-yourpasswordagain-ph": "Wszkryflej hasło jeszcze roz",
+       "createacct-yourpasswordagain-ph": "Wkludź hasło jeszcze rŏz",
        "userlogin-remembermypassword": "Ńy wylogůwywuj mje",
        "userlogin-signwithsecure": "Użyj bezpjecznygo połůnczyńa",
        "yourdomainname": "Twoja domyna",
        "userlogin-createanother": "Twůrz inksze kůnto",
        "createacct-emailrequired": "E-brif",
        "createacct-emailoptional": "E-brif (uopcjůnalne)",
-       "createacct-email-ph": "Wszkryflej swůj adres do e-brifa",
+       "createacct-email-ph": "Wkludź swojã adresã e-brifa",
        "createacct-another-email-ph": "Nastow e-brif",
        "createaccountmail": "Użyj chwilowygo hasła losowo genyrowanygo a wyślij je na wrychtowany adres e-brifa.",
        "createacct-realname": "Prawdźiwe imje a nazwisko (uopcjůnalńe)",
diff --git a/maintenance/categoryChangesAsRdf.php b/maintenance/categoryChangesAsRdf.php
new file mode 100644 (file)
index 0000000..a12cda7
--- /dev/null
@@ -0,0 +1,542 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ */
+use Wikimedia\Purtle\RdfWriter;
+use Wikimedia\Purtle\TurtleRdfWriter;
+use Wikimedia\Rdbms\IDatabase;
+
+require_once __DIR__ . '/Maintenance.php';
+
+/**
+ * Maintenance script to provide RDF representation of the recent changes in category tree.
+ *
+ * @ingroup Maintenance
+ * @since 1.30
+ */
+class CategoryChangesAsRdf extends Maintenance {
+       /**
+        * Insert query
+        */
+       const SPARQL_INSERT = <<<SPARQL
+INSERT DATA {
+%s
+};
+
+SPARQL;
+
+       /**
+        * Delete/Insert query
+        */
+       const SPARQL_DELETE_INSERT = <<<SPARQLDI
+DELETE {
+?category ?x ?y
+} INSERT {
+%s
+} WHERE {
+   VALUES ?category {
+     %s
+   }
+};
+
+SPARQLDI;
+
+       /**
+        * @var RdfWriter
+        */
+       private $rdfWriter;
+       /**
+        * Categories RDF helper.
+        * @var CategoriesRdf
+        */
+       private $categoriesRdf;
+
+       private $startTS;
+       private $endTS;
+
+       /**
+        * List of processed page IDs,
+        * so we don't try to process same thing twice
+        * @var int[]
+        */
+       protected $processed = [];
+
+       public function __construct() {
+               parent::__construct();
+
+               $this->addDescription( "Generate RDF dump of category changes in a wiki." );
+
+               $this->setBatchSize( 200 );
+               $this->addOption( 'output', "Output file (default is stdout). Will be overwritten.", false,
+                       true, 'o' );
+               $this->addOption( 'start', 'Starting timestamp (inclusive), in ISO or Mediawiki format.',
+                       true, true, 's' );
+               $this->addOption( 'end', 'Ending timestamp (exclusive), in ISO or Mediawiki format.', true,
+                       true, 'e' );
+       }
+
+       /**
+        * Initialize external service classes.
+        */
+       public function initialize() {
+               // SPARQL Update syntax is close to Turtle format, so we can use Turtle writer.
+               $this->rdfWriter = new TurtleRdfWriter();
+               $this->categoriesRdf = new CategoriesRdf( $this->rdfWriter );
+       }
+
+       public function execute() {
+               global $wgRCMaxAge;
+
+               $this->initialize();
+
+               $startTS = new MWTimestamp( $this->getOption( "start" ) );
+               $endTS = new MWTimestamp( $this->getOption( "end" ) );
+               $now = new MWTimestamp();
+
+               if ( $now->getTimestamp() - $startTS->getTimestamp() > $wgRCMaxAge ) {
+                       $this->error( "Start timestamp too old, maximum RC age is $wgRCMaxAge!" );
+               }
+               if ( $now->getTimestamp() - $endTS->getTimestamp() > $wgRCMaxAge ) {
+                       $this->error( "End timestamp too old, maximum RC age is $wgRCMaxAge!" );
+               }
+
+               $this->startTS = $startTS->getTimestamp();
+               $this->endTS = $endTS->getTimestamp();
+
+               $outFile = $this->getOption( 'output', 'php://stdout' );
+               if ( $outFile === '-' ) {
+                       $outFile = 'php://stdout';
+               }
+
+               $output = fopen( $outFile, 'wb' );
+
+               $this->categoriesRdf->setupPrefixes();
+               $this->rdfWriter->start();
+
+               $prefixes = $this->getRdf();
+               // We have to strip @ from prefix, since SPARQL UPDATE doesn't use them
+               // Also strip dot at the end.
+               $prefixes = preg_replace( [ '/^@/m', '/\s*[.]$/m' ], '', $prefixes );
+               fwrite( $output, $prefixes );
+
+               $dbr = $this->getDB( DB_REPLICA, [ 'vslow' ] );
+
+               // Deletes go first because if the page was deleted, other changes
+               // do not matter. This only gets true deletes, i.e. not pages that were restored.
+               $this->handleDeletes( $dbr, $output );
+               // Moves go before additions because if category is moved, we should not process creation
+               // as it would produce wrong data - because create row has old title
+               $this->handleMoves( $dbr, $output );
+               // We need to handle restores too since delete may have happened in previous update.
+               $this->handleRestores( $dbr, $output );
+               $this->handleAdds( $dbr, $output );
+               $this->handleChanges( $dbr, $output );
+
+               // Update timestamp
+               fwrite( $output, $this->updateTS( $this->endTS ) );
+       }
+
+       /**
+        * Get SPARQL for updating set of categories
+        * @param IDatabase $dbr
+        * @param string[] $deleteUrls List of URIs to be deleted, with <>
+        * @param string[] $pages List of categories: id => title
+        * @param string $mark Marks which operation requests the query
+        * @return string SPARQL query
+        */
+       private function getCategoriesUpdate( IDatabase $dbr, $deleteUrls, $pages, $mark ) {
+               if ( empty( $deleteUrls ) ) {
+                       return "";
+               }
+
+               if ( !empty( $pages ) ) {
+                       $this->writeParentCategories( $dbr, $pages );
+               }
+
+               return "# $mark\n" . sprintf( self::SPARQL_DELETE_INSERT,
+                               $this->getRdf(),
+                               implode( ' ', $deleteUrls ) );
+       }
+
+       /**
+        * Write data for a set of categories
+        * @param IDatabase $dbr
+        * @param string[] $pages List of categories: id => title
+        */
+       private function writeParentCategories( IDatabase $dbr, $pages ) {
+               foreach ( $this->getCategoryLinksIterator( $dbr, array_keys( $pages ) ) as $row ) {
+                       $this->categoriesRdf->writeCategoryLinkData( $pages[$row->cl_from], $row->cl_to );
+               }
+       }
+
+       /**
+        * Generate SPARQL Update code for updating dump timestamp
+        * @param string|int $timestamp Timestamp for last change
+        * @return string SPARQL Update query for timestamp.
+        */
+       public function updateTS( $timestamp ) {
+               $dumpUrl = '<' . $this->categoriesRdf->getDumpURI() . '>';
+               $ts = wfTimestamp( TS_ISO_8601, $timestamp );
+               $tsQuery = <<<SPARQL
+DELETE {
+  $dumpUrl schema:dateModified ?o .
+}
+WHERE {
+  $dumpUrl schema:dateModified ?o .
+};
+INSERT DATA {
+  $dumpUrl schema:dateModified "$ts"^^xsd:dateTime .
+}
+
+SPARQL;
+               return $tsQuery;
+       }
+
+       /**
+        * Set up standard iterator for retrieving category changes.
+        * @param IDatabase $dbr
+        * @param string[] $columns List of additional fields to get
+        * @param string[] $extra_tables List of additional tables to join
+        * @return BatchRowIterator
+        */
+       private function setupChangesIterator(
+               IDatabase $dbr,
+               array $columns = [],
+               array $extra_tables = []
+       ) {
+               $tables = [ 'recentchanges', 'page_props', 'category' ];
+               if ( $extra_tables ) {
+                       $tables += $extra_tables;
+               }
+               $it = new BatchRowIterator( $dbr,
+                       $tables,
+                       [ 'rc_timestamp' ],
+                       $this->mBatchSize
+               );
+               $this->addTimestampConditions( $it, $dbr );
+               $it->addJoinConditions(
+                       [
+                               'page_props' => [
+                                       'LEFT JOIN', [ 'pp_propname' => 'hiddencat', 'pp_page = rc_cur_id' ]
+                               ],
+                               'category' => [
+                                       'LEFT JOIN', [ 'cat_title = rc_title' ]
+                               ]
+                       ]
+               );
+               $it->setFetchColumns( array_merge( $columns, [
+                       'rc_title',
+                       'rc_cur_id',
+                       'pp_propname',
+                       'cat_pages',
+                       'cat_subcats',
+                       'cat_files'
+               ] ) );
+               return $it;
+       }
+
+       /**
+        * Fetch newly created categories
+        * @param IDatabase $dbr
+        * @return BatchRowIterator
+        */
+       protected function getNewCatsIterator( IDatabase $dbr ) {
+               $it = $this->setupChangesIterator( $dbr );
+               $it->addConditions( [
+                       'rc_namespace' => NS_CATEGORY,
+                       'rc_new' => 1,
+               ] );
+               return $it;
+       }
+
+       /**
+        * Fetch moved categories
+        * @param IDatabase $dbr
+        * @return BatchRowIterator
+        */
+       protected function getMovedCatsIterator( IDatabase $dbr ) {
+               $it = $this->setupChangesIterator( $dbr, [ 'page_title', 'page_namespace' ], [ 'page' ] );
+               $it->addConditions( [
+                       'rc_namespace' => NS_CATEGORY,
+                       'rc_new' => 0,
+                       'rc_log_type' => 'move',
+                       'rc_type' => RC_LOG,
+               ] );
+               $it->addJoinConditions( [
+                       'page' => [ 'INNER JOIN', 'rc_cur_id = page_id' ],
+               ] );
+               $this->addIndex( $it );
+               return $it;
+       }
+
+       /**
+        * Fetch deleted categories
+        * @param IDatabase $dbr
+        * @return BatchRowIterator
+        */
+       protected function getDeletedCatsIterator( IDatabase $dbr ) {
+               $it = new BatchRowIterator( $dbr,
+                       'recentchanges',
+                       [ 'rc_timestamp' ],
+                       $this->mBatchSize
+               );
+               $this->addTimestampConditions( $it, $dbr );
+               $it->addConditions( [
+                       'rc_namespace' => NS_CATEGORY,
+                       'rc_new' => 0,
+                       'rc_log_type' => 'delete',
+                       'rc_log_action' => 'delete',
+                       'rc_type' => RC_LOG,
+                       // We will fetch ones that do not have page record. If they do,
+                       // this means they were restored, thus restoring handler will pick it up.
+                       'NOT EXISTS (SELECT * FROM page WHERE page_id = rc_cur_id)',
+               ] );
+               $this->addIndex( $it );
+               $it->setFetchColumns( [ 'rc_cur_id', 'rc_title' ] );
+               return $it;
+       }
+
+       /**
+        * Fetch restored categories
+        * @param IDatabase $dbr
+        * @return BatchRowIterator
+        */
+       protected function getRestoredCatsIterator( IDatabase $dbr ) {
+               $it = $this->setupChangesIterator( $dbr );
+               $it->addConditions( [
+                       'rc_namespace' => NS_CATEGORY,
+                       'rc_new' => 0,
+                       'rc_log_type' => 'delete',
+                       'rc_log_action' => 'restore',
+                       'rc_type' => RC_LOG,
+                       // We will only fetch ones that have page record
+                       'EXISTS (SELECT page_id FROM page WHERE page_id = rc_cur_id)',
+               ] );
+               $this->addIndex( $it );
+               return $it;
+       }
+
+       /**
+        * Fetch categorization changes
+        * @param IDatabase $dbr
+        * @return BatchRowIterator
+        */
+       protected function getChangedCatsIterator( IDatabase $dbr ) {
+               $it = $this->setupChangesIterator( $dbr );
+               $it->addConditions( [
+                       'rc_namespace' => NS_CATEGORY,
+                       'rc_new' => 0,
+                       'rc_type' => [ RC_EDIT, RC_CATEGORIZE ],
+               ] );
+               $this->addIndex( $it );
+               return $it;
+       }
+
+       /**
+        * Add timestamp limits to iterator
+        * @param BatchRowIterator $it Iterator
+        * @param IDatabase $dbr
+        */
+       private function addTimestampConditions( BatchRowIterator $it, IDatabase $dbr ) {
+               $it->addConditions( [
+                       'rc_timestamp >= ' . $dbr->addQuotes( $dbr->timestamp( $this->startTS ) ),
+                       'rc_timestamp < ' . $dbr->addQuotes( $dbr->timestamp( $this->endTS ) ),
+               ] );
+       }
+
+       /**
+        * Need to force index, somehow on terbium the optimizer chooses wrong one
+        * @param BatchRowIterator $it
+        */
+       private function addIndex( BatchRowIterator $it ) {
+               $it->addOptions( [
+                       'USE INDEX' => [ 'recentchanges' => 'new_name_timestamp' ]
+               ] );
+       }
+
+       /**
+        * Get iterator for links for categories.
+        * @param IDatabase $dbr
+        * @param array $ids List of page IDs
+        * @return Traversable
+        */
+       protected function getCategoryLinksIterator( IDatabase $dbr, array $ids ) {
+               $it = new BatchRowIterator(
+                       $dbr,
+                       'categorylinks',
+                       [ 'cl_from', 'cl_to' ],
+                       $this->mBatchSize
+               );
+               $it->addConditions( [
+                       'cl_type' => 'subcat',
+                       'cl_from' => $ids
+               ] );
+               $it->setFetchColumns( [ 'cl_from', 'cl_to' ] );
+               return new RecursiveIteratorIterator( $it );
+       }
+
+       /**
+        * Get accumulated RDF.
+        * @return string
+        */
+       public function getRdf() {
+               return $this->rdfWriter->drain();
+       }
+
+       /**
+        * Handle category deletes.
+        * @param IDatabase $dbr
+        * @param resource $output File to write the output
+        */
+       public function handleDeletes( IDatabase $dbr, $output ) {
+               // This only does "true" deletes - i.e. those that the page stays deleted
+               foreach ( $this->getDeletedCatsIterator( $dbr ) as $batch ) {
+                       $deleteUrls = [];
+                       foreach ( $batch as $row ) {
+                               // This can produce duplicates, we don't care
+                               $deleteUrls[] = '<' . $this->categoriesRdf->labelToUrl( $row->rc_title ) . '>';
+                               $this->processed[$row->rc_cur_id] = true;
+                       }
+                       fwrite( $output, $this->getCategoriesUpdate( $dbr, $deleteUrls, [], "Deletes" ) );
+               }
+       }
+
+       /**
+        * Write category data to RDF.
+        * @param stdclass $row Database row
+        */
+       private function writeCategoryData( $row ) {
+               $this->categoriesRdf->writeCategoryData(
+                       $row->rc_title,
+                       $row->pp_propname === 'hiddencat',
+                       (int)$row->cat_pages - (int)$row->cat_subcats - (int)$row->cat_files,
+                       (int)$row->cat_subcats
+               );
+       }
+
+       /**
+        * @param IDatabase $dbr
+        * @param resource $output
+        */
+       public function handleMoves( IDatabase $dbr, $output ) {
+               foreach ( $this->getMovedCatsIterator( $dbr ) as $batch ) {
+                       $pages = [];
+                       $deleteUrls = [];
+                       foreach ( $batch as $row ) {
+                               $deleteUrls[] = '<' . $this->categoriesRdf->labelToUrl( $row->rc_title ) . '>';
+
+                               if ( isset( $this->processed[$row->rc_cur_id] ) ) {
+                                       // We already captured this one before
+                                       continue;
+                               }
+
+                               if ( $row->page_namespace != NS_CATEGORY ) {
+                                       // If page was moved out of Category:, we'll just delete
+                                       continue;
+                               }
+                               $row->rc_title = $row->page_title;
+                               $this->writeCategoryData( $row );
+                               $pages[$row->rc_cur_id] = $row->page_title;
+                               $this->processed[$row->rc_cur_id] = true;
+                       }
+
+                       fwrite( $output, $this->getCategoriesUpdate( $dbr, $deleteUrls, $pages, "Moves" ) );
+               }
+       }
+
+       /**
+        * @param IDatabase $dbr
+        * @param resource $output
+        */
+       public function handleRestores( IDatabase $dbr, $output ) {
+               fwrite( $output, "# Restores\n" );
+               // This will only find those restores that were not deleted later.
+               foreach ( $this->getRestoredCatsIterator( $dbr ) as $batch ) {
+                       $pages = [];
+                       foreach ( $batch as $row ) {
+                               if ( isset( $this->processed[$row->rc_cur_id] ) ) {
+                                       // We already captured this one before
+                                       continue;
+                               }
+                               $this->writeCategoryData( $row );
+                               $pages[$row->rc_cur_id] = $row->rc_title;
+                               $this->processed[$row->rc_cur_id] = true;
+                       }
+
+                       if ( empty( $pages ) ) {
+                               continue;
+                       }
+
+                       $this->writeParentCategories( $dbr, $pages );
+
+                       fwrite( $output, sprintf( self::SPARQL_INSERT, $this->getRdf() ) );
+               }
+       }
+
+       /**
+        * @param IDatabase $dbr
+        * @param resource $output
+        */
+       public function handleAdds( IDatabase $dbr, $output ) {
+               fwrite( $output, "# Additions\n" );
+               foreach ( $this->getNewCatsIterator( $dbr ) as $batch ) {
+                       $pages = [];
+                       foreach ( $batch as $row ) {
+                               if ( isset( $this->processed[$row->rc_cur_id] ) ) {
+                                       // We already captured this one before
+                                       continue;
+                               }
+                               $this->writeCategoryData( $row );
+                               $pages[$row->rc_cur_id] = $row->rc_title;
+                               $this->processed[$row->rc_cur_id] = true;
+                       }
+
+                       if ( empty( $pages ) ) {
+                               continue;
+                       }
+
+                       $this->writeParentCategories( $dbr, $pages );
+                       fwrite( $output, sprintf( self::SPARQL_INSERT, $this->getRdf() ) );
+               }
+       }
+
+       /**
+        * @param IDatabase $dbr
+        * @param resource $output
+        */
+       public function handleChanges( IDatabase $dbr, $output ) {
+               foreach ( $this->getChangedCatsIterator( $dbr ) as $batch ) {
+                       $pages = [];
+                       $deleteUrls = [];
+                       foreach ( $batch as $row ) {
+                               if ( isset( $this->processed[$row->rc_cur_id] ) ) {
+                                       // We already captured this one before
+                                       continue;
+                               }
+                               $this->writeCategoryData( $row );
+                               $pages[$row->rc_cur_id] = $row->rc_title;
+                               $this->processed[$row->rc_cur_id] = true;
+                               $deleteUrls[] = '<' . $this->categoriesRdf->labelToUrl( $row->rc_title ) . '>';
+                       }
+
+                       fwrite( $output, $this->getCategoriesUpdate( $dbr, $deleteUrls, $pages, "Changes" ) );
+               }
+       }
+}
+
+$maintClass = CategoryChangesAsRdf::class;
+require_once RUN_MAINTENANCE_IF_MAIN;
index 2bc0c80..f928f09 100644 (file)
     "grunt-jsonlint": "1.1.0",
     "grunt-karma": "2.0.0",
     "grunt-stylelint": "0.10.0",
-    "karma": "1.7.1",
+    "karma": "2.0.2",
     "karma-chrome-launcher": "2.2.0",
     "karma-firefox-launcher": "1.0.1",
     "karma-mocha-reporter": "2.2.5",
     "karma-qunit": "2.0.1",
     "postcss-less": "1.1.5",
-    "qunit": "2.5.0",
+    "qunit": "2.6.0",
     "stylelint": "9.2.0",
     "stylelint-config-wikimedia": "0.4.3",
     "wdio-junit-reporter": "0.2.0",
index 27ecb1a..d880e8b 100644 (file)
@@ -55,7 +55,7 @@ figure[ typeof*='mw:Audio' ] {
 
        &.mw-halign-right {
                /* @noflip */
-               margin: 0.5em 0 1.3em 1.4em;
+               margin: 0 0 0.5em 0.5em;
                /* @noflip */
                clear: right;
                /* @noflip */
@@ -64,7 +64,7 @@ figure[ typeof*='mw:Audio' ] {
 
        &.mw-halign-left {
                /* @noflip */
-               margin: 0.5em 1.4em 1.3em 0;
+               margin: 0 0.5em 0.5em 0;
                /* @noflip */
                clear: left;
                /* @noflip */
@@ -116,6 +116,15 @@ figure[ typeof~='mw:Audio/Frame' ] {
        clear: right;
        float: right;
 
+       &.mw-halign-left {
+               /* @noflip */
+               margin: 0.5em 1.4em 1.3em 0;
+       }
+       &.mw-halign-right {
+               /* @noflip */
+               margin: 0.5em 0 1.3em 1.4em;
+       }
+
        > *:first-child {
                > img,
                > video {
diff --git a/tests/phpunit/data/categoriesrdf/change.sparql b/tests/phpunit/data/categoriesrdf/change.sparql
new file mode 100644 (file)
index 0000000..d7ec83a
--- /dev/null
@@ -0,0 +1,16 @@
+# Changes
+DELETE {
+?category ?x ?y
+} INSERT {
+
+<http://acme.test/wiki/Category:Changed_category> a mediawiki:Category ;
+       rdfs:label "Changed category" ;
+       mediawiki:pages "7"^^xsd:integer ;
+       mediawiki:subcategories "2"^^xsd:integer ;
+       mediawiki:isInCategory <http://acme.test/wiki/Category:Parent_of_30> .
+
+} WHERE {
+   VALUES ?category {
+     <http://acme.test/wiki/Category:Changed_category>
+   }
+};
diff --git a/tests/phpunit/data/categoriesrdf/delete.sparql b/tests/phpunit/data/categoriesrdf/delete.sparql
new file mode 100644 (file)
index 0000000..7fb642d
--- /dev/null
@@ -0,0 +1,10 @@
+# Deletes
+DELETE {
+?category ?x ?y
+} INSERT {
+
+} WHERE {
+   VALUES ?category {
+     <http://acme.test/wiki/Category:Test> <http://acme.test/wiki/Category:Test_2>
+   }
+};
diff --git a/tests/phpunit/data/categoriesrdf/move.sparql b/tests/phpunit/data/categoriesrdf/move.sparql
new file mode 100644 (file)
index 0000000..c9f284e
--- /dev/null
@@ -0,0 +1,24 @@
+# Moves
+DELETE {
+?category ?x ?y
+} INSERT {
+
+<http://acme.test/wiki/Category:MovedTo> a mediawiki:Category ;
+       rdfs:label "MovedTo" ;
+       mediawiki:pages "7"^^xsd:integer ;
+       mediawiki:subcategories "2"^^xsd:integer .
+
+<http://acme.test/wiki/Category:AlsoMoved> a mediawiki:Category ;
+       rdfs:label "AlsoMoved" ;
+       mediawiki:pages "7"^^xsd:integer ;
+       mediawiki:subcategories "2"^^xsd:integer .
+
+<http://acme.test/wiki/Category:MovedTo> mediawiki:isInCategory <http://acme.test/wiki/Category:Parent_of_4> .
+
+<http://acme.test/wiki/Category:AlsoMoved> mediawiki:isInCategory <http://acme.test/wiki/Category:Parent_of_5> .
+
+} WHERE {
+   VALUES ?category {
+     <http://acme.test/wiki/Category:Test> <http://acme.test/wiki/Category:MovedTo> <http://acme.test/wiki/Category:Test_2> <http://acme.test/wiki/Category:Test_3> <http://acme.test/wiki/Category:Test_4>
+   }
+};
diff --git a/tests/phpunit/data/categoriesrdf/new.sparql b/tests/phpunit/data/categoriesrdf/new.sparql
new file mode 100644 (file)
index 0000000..f9a742d
--- /dev/null
@@ -0,0 +1,19 @@
+# Additions
+INSERT DATA {
+
+<http://acme.test/wiki/Category:New_category> a mediawiki:Category ;
+       rdfs:label "New category" ;
+       mediawiki:pages "7"^^xsd:integer ;
+       mediawiki:subcategories "2"^^xsd:integer .
+
+<http://acme.test/wiki/Category:%D0%9D%D0%BE%D0%B2%D0%B0%D1%8F_%D0%BA%D0%B0%D1%82%D0%B5%D0%B3%D0%BE%D1%80%D0%B8%D1%8F_%F0%9F%98%83> a mediawiki:Category,
+               mediawiki:HiddenCategory ;
+       rdfs:label "Новая категория 😃" ;
+       mediawiki:pages "7"^^xsd:integer ;
+       mediawiki:subcategories "2"^^xsd:integer .
+
+<http://acme.test/wiki/Category:New_category> mediawiki:isInCategory <http://acme.test/wiki/Category:Parent_of_20> .
+
+<http://acme.test/wiki/Category:%D0%9D%D0%BE%D0%B2%D0%B0%D1%8F_%D0%BA%D0%B0%D1%82%D0%B5%D0%B3%D0%BE%D1%80%D0%B8%D1%8F_%F0%9F%98%83> mediawiki:isInCategory <http://acme.test/wiki/Category:Parent_of_21> .
+
+};
diff --git a/tests/phpunit/data/categoriesrdf/restore.sparql b/tests/phpunit/data/categoriesrdf/restore.sparql
new file mode 100644 (file)
index 0000000..16c0561
--- /dev/null
@@ -0,0 +1,10 @@
+# Restores
+INSERT DATA {
+
+<http://acme.test/wiki/Category:Restored_cat> a mediawiki:Category ;
+       rdfs:label "Restored cat" ;
+       mediawiki:pages "7"^^xsd:integer ;
+       mediawiki:subcategories "2"^^xsd:integer ;
+       mediawiki:isInCategory <http://acme.test/wiki/Category:Parent_of_10> .
+
+};
diff --git a/tests/phpunit/data/categoriesrdf/updatets.txt b/tests/phpunit/data/categoriesrdf/updatets.txt
new file mode 100644 (file)
index 0000000..426bb92
--- /dev/null
@@ -0,0 +1,9 @@
+DELETE {
+  <http://acme.test/wiki/Special:CategoryDump> schema:dateModified ?o .
+}
+WHERE {
+  <http://acme.test/wiki/Special:CategoryDump> schema:dateModified ?o .
+};
+INSERT DATA {
+  <http://acme.test/wiki/Special:CategoryDump> schema:dateModified "2017-08-25T00:29:09Z"^^xsd:dateTime .
+}
index a84cc04..6e23e53 100644 (file)
@@ -153,64 +153,75 @@ class LBFactoryTest extends MediaWikiTestCase {
                $lb->closeAll();
        }
 
-       public function testLBFactoryMulti() {
-               global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir;
+       public function testLBFactoryMultiConns() {
+               $factory = $this->newLBFactoryMultiLBs();
 
-               $factory = new LBFactoryMulti( [
-                       'sectionsByDB' => [
-                               's1wiki' => 's1',
-                       ],
-                       'sectionLoads' => [
-                               's1' => [
-                                       'test-db3' => 0,
-                                       'test-db4' => 100,
-                               ],
-                               'DEFAULT' => [
-                                       'test-db1' => 0,
-                                       'test-db2' => 100,
-                               ]
-                       ],
-                       'serverTemplate' => [
-                               'dbname'      => $wgDBname,
-                               'user'        => $wgDBuser,
-                               'password'    => $wgDBpassword,
-                               'type'        => $wgDBtype,
-                               'dbDirectory' => $wgSQLiteDataDir,
-                               'flags'       => DBO_DEFAULT
-                       ],
-                       'hostsByName' => [
-                               'test-db1'  => $wgDBserver,
-                               'test-db2'  => $wgDBserver,
-                               'test-db3'  => $wgDBserver,
-                               'test-db4'  => $wgDBserver
-                       ],
-                       'loadMonitorClass' => LoadMonitorNull::class
-               ] );
-               $lb = $factory->getMainLB();
-
-               $dbw = $lb->getConnection( DB_MASTER );
+               $dbw = $factory->getMainLB()->getConnection( DB_MASTER );
                $this->assertTrue( $dbw->getLBInfo( 'master' ), 'master shows as master' );
 
-               $dbr = $lb->getConnection( DB_REPLICA );
+               $dbr = $factory->getMainLB()->getConnection( DB_REPLICA );
                $this->assertTrue( $dbr->getLBInfo( 'replica' ), 'slave shows as slave' );
 
-               // Test that LoadBalancer instances made during commitMasterChanges() do not throw
-               // DBTransactionError due to transaction ROUND_* stages being mismatched.
+               // Destructor should trigger without round stage errors
+               unset( $factory );
+       }
+
+       public function testLBFactoryMultiRoundCallbacks() {
+               $called = 0;
+               $countLBsFunc = function ( LBFactoryMulti $factory ) {
+                       $count = 0;
+                       $factory->forEachLB( function () use ( &$count ) {
+                               ++$count;
+                       } );
+
+                       return $count;
+               };
+
+               $factory = $this->newLBFactoryMultiLBs();
+               $this->assertEquals( 0, $countLBsFunc( $factory ) );
+               $dbw = $factory->getMainLB()->getConnection( DB_MASTER );
+               $this->assertEquals( 1, $countLBsFunc( $factory ) );
+               // Test that LoadBalancer instances made during pre-commit callbacks in do not
+               // throw DBTransactionError due to transaction ROUND_* stages being mismatched.
                $factory->beginMasterChanges( __METHOD__ );
-               $dbw->onTransactionPreCommitOrIdle( function () use ( $factory ) {
+               $dbw->onTransactionPreCommitOrIdle( function () use ( $factory, &$called ) {
+                       ++$called;
                        // Trigger s1 LoadBalancer instantiation during "finalize" stage.
                        // There is no s1wiki DB to select so it is not in getConnection(),
                        // but this fools getMainLB() at least.
                        $factory->getMainLB( 's1wiki' )->getConnection( DB_MASTER );
                } );
                $factory->commitMasterChanges( __METHOD__ );
+               $this->assertEquals( 1, $called );
+               $this->assertEquals( 2, $countLBsFunc( $factory ) );
+               $factory->shutdown();
+               $factory->closeAll();
 
-               $count = 0;
-               $factory->forEachLB( function () use ( &$count ) {
-                       ++$count;
+               $called = 0;
+               $factory = $this->newLBFactoryMultiLBs();
+               $this->assertEquals( 0, $countLBsFunc( $factory ) );
+               $dbw = $factory->getMainLB()->getConnection( DB_MASTER );
+               $this->assertEquals( 1, $countLBsFunc( $factory ) );
+               // Test that LoadBalancer instances made during pre-commit callbacks in do not
+               // throw DBTransactionError due to transaction ROUND_* stages being mismatched.hrow
+               // DBTransactionError due to transaction ROUND_* stages being mismatched.
+               $factory->beginMasterChanges( __METHOD__ );
+               $dbw->query( "SELECT 1 as t", __METHOD__ );
+               $dbw->onTransactionResolution( function () use ( $factory, &$called ) {
+                       ++$called;
+                       // Trigger s1 LoadBalancer instantiation during "finalize" stage.
+                       // There is no s1wiki DB to select so it is not in getConnection(),
+                       // but this fools getMainLB() at least.
+                       $factory->getMainLB( 's1wiki' )->getConnection( DB_MASTER );
                } );
-               $this->assertEquals( 2, $count );
+               $factory->commitMasterChanges( __METHOD__ );
+               $this->assertEquals( 1, $called );
+               $this->assertEquals( 2, $countLBsFunc( $factory ) );
+               $factory->shutdown();
+               $factory->closeAll();
 
+               $factory = $this->newLBFactoryMultiLBs();
+               $dbw = $factory->getMainLB()->getConnection( DB_MASTER );
                // DBTransactionError should not be thrown
                $ran = 0;
                $dbw->onTransactionPreCommitOrIdle( function () use ( &$ran ) {
@@ -223,6 +234,41 @@ class LBFactoryTest extends MediaWikiTestCase {
                $factory->closeAll();
        }
 
+       private function newLBFactoryMultiLBs() {
+               global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir;
+
+               return new LBFactoryMulti( [
+                       'sectionsByDB' => [
+                               's1wiki' => 's1',
+                       ],
+                       'sectionLoads' => [
+                               's1' => [
+                                       'test-db3' => 0,
+                                       'test-db4' => 100,
+                               ],
+                               'DEFAULT' => [
+                                       'test-db1' => 0,
+                                       'test-db2' => 100,
+                               ]
+                       ],
+                       'serverTemplate' => [
+                               'dbname' => $wgDBname,
+                               'user' => $wgDBuser,
+                               'password' => $wgDBpassword,
+                               'type' => $wgDBtype,
+                               'dbDirectory' => $wgSQLiteDataDir,
+                               'flags' => DBO_DEFAULT
+                       ],
+                       'hostsByName' => [
+                               'test-db1' => $wgDBserver,
+                               'test-db2' => $wgDBserver,
+                               'test-db3' => $wgDBserver,
+                               'test-db4' => $wgDBserver
+                       ],
+                       'loadMonitorClass' => LoadMonitorNull::class
+               ] );
+       }
+
        /**
         * @covers \Wikimedia\Rdbms\ChronologyProtector
         */
diff --git a/tests/phpunit/maintenance/categoryChangesRdfTest.php b/tests/phpunit/maintenance/categoryChangesRdfTest.php
new file mode 100644 (file)
index 0000000..30a56f4
--- /dev/null
@@ -0,0 +1,263 @@
+<?php
+
+/**
+ * Tests for CategoryChangesAsRdf recent changes exporter.
+ *  @covers CategoryChangesAsRdf
+ */
+class CategoryChangesRdfTest extends MediaWikiLangTestCase {
+
+       public function setUp() {
+               parent::setUp();
+               $this->setMwGlobals( [
+                       'wgServer' => 'http://acme.test',
+                       'wgCanonicalServer' => 'http://acme.test',
+                       'wgArticlePath' => '/wiki/$1',
+               ] );
+       }
+
+       public function provideCategoryData() {
+               return [
+                       'delete category' => [
+                               __DIR__ . "/../data/categoriesrdf/delete.sparql",
+                               'getDeletedCatsIterator',
+                               'handleDeletes',
+                               [
+                                       (object)[ 'rc_title' => 'Test', 'rc_cur_id' => 1, '_processed' => 1 ],
+                                       (object)[ 'rc_title' => 'Test 2', 'rc_cur_id' => 2, '_processed' => 2 ],
+                               ],
+                       ],
+                       'move category' => [
+                               __DIR__ . "/../data/categoriesrdf/move.sparql",
+                               'getMovedCatsIterator',
+                               'handleMoves',
+                               [
+                                       (object)[
+                                               'rc_title' => 'Test',
+                                               'rc_cur_id' => 4,
+                                               'page_title' => 'MovedTo',
+                                               'page_namespace' => NS_CATEGORY,
+                                               '_processed' => 4,
+                                               'pp_propname' => null,
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                                       (object)[
+                                               'rc_title' => 'MovedTo',
+                                               'rc_cur_id' => 4,
+                                               'page_title' => 'MovedAgain',
+                                               'page_namespace' => NS_CATEGORY,
+                                               'pp_propname' => 'hiddencat',
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                                       (object)[
+                                               'rc_title' => 'Test 2',
+                                               'rc_cur_id' => 5,
+                                               'page_title' => 'AlsoMoved',
+                                               'page_namespace' => NS_CATEGORY,
+                                               '_processed' => 5,
+                                               'pp_propname' => null,
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                                       (object)[
+                                               'rc_title' => 'Test 3',
+                                               'rc_cur_id' => 6,
+                                               'page_title' => 'MovedOut',
+                                               'page_namespace' => NS_MAIN,
+                                               'pp_propname' => null,
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                                       (object)[
+                                               'rc_title' => 'Test 4',
+                                               'rc_cur_id' => 7,
+                                               'page_title' => 'Already Done',
+                                               'page_namespace' => NS_CATEGORY,
+                                               'pp_propname' => null,
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                               ],
+                               [ 7 => true ],
+                       ],
+                       'restore deleted category' => [
+                               __DIR__ . "/../data/categoriesrdf/restore.sparql",
+                               'getRestoredCatsIterator',
+                               'handleRestores',
+                               [
+                                       (object)[
+                                               'rc_title' => 'Restored cat',
+                                               'rc_cur_id' => 10,
+                                               '_processed' => 10,
+                                               'pp_propname' => null,
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                                       (object)[
+                                               'rc_title' => 'Restored again',
+                                               'rc_cur_id' => 10,
+                                               'pp_propname' => null,
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                                       (object)[
+                                               'rc_title' => 'Already seen',
+                                               'rc_cur_id' => 11,
+                                               'pp_propname' => null,
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                               ],
+                               [ 11 => true ],
+                       ],
+                       'new page' => [
+                               __DIR__ . "/../data/categoriesrdf/new.sparql",
+                               'getNewCatsIterator',
+                               'handleAdds',
+                               [
+                                       (object)[
+                                               'rc_title' => 'New category',
+                                               'rc_cur_id' => 20,
+                                               '_processed' => 20,
+                                               'pp_propname' => null,
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                                       (object)[
+                                               'rc_title' => 'Новая категория 😃',
+                                               'rc_cur_id' => 21,
+                                               '_processed' => 21,
+                                               'pp_propname' => 'hiddencat',
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                                       (object)[
+                                               'rc_title' => 'Processed already',
+                                               'rc_cur_id' => 22,
+                                       ],
+                               ],
+                               [ 22 => true ],
+                       ],
+                       'change in categories' => [
+                               __DIR__ . "/../data/categoriesrdf/change.sparql",
+                               'getChangedCatsIterator',
+                               'handleChanges',
+                               [
+                                       (object)[
+                                               'rc_title' => 'Changed category',
+                                               'rc_cur_id' => 30,
+                                               '_processed' => 30,
+                                               'pp_propname' => null,
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                                       (object)[
+                                               'rc_title' => 'Changed again',
+                                               'rc_cur_id' => 30,
+                                               'pp_propname' => null,
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                                       (object)[
+                                               'rc_title' => 'Processed already',
+                                               'rc_cur_id' => 31,
+                                               'pp_propname' => null,
+                                               'cat_pages' => 10,
+                                               'cat_subcats' => 2,
+                                               'cat_files' => 1,
+                                       ],
+                               ],
+                               [ 31 => true ],
+                       ],
+
+               ];
+       }
+
+       /**
+        * Mock category links iterator.
+        * @param $dbr
+        * @param array $ids
+        * @return array
+        */
+       public function getCategoryLinksIterator( $dbr, array $ids ) {
+               $res = [];
+               foreach ( $ids as $pageid ) {
+                       $res[] = (object)[ 'cl_from' => $pageid, 'cl_to' => "Parent of $pageid" ];
+               }
+               return $res;
+       }
+
+       /**
+        * @dataProvider provideCategoryData
+        * @param string $testFileName Name of the test, defines filename with expected results.
+        * @param string $iterator Iterator method name to mock
+        * @param string $handler Handler method to call
+        * @param array $result Result to be returned from mock iterator
+        * @param array $preProcessed List of pre-processed items
+        */
+       public function testSparqlUpdate( $testFileName, $iterator, $handler, $result,
+                       array $preProcessed = [] ) {
+               $dumpScript =
+                       $this->getMockBuilder( CategoryChangesAsRdf::class )
+                               ->setMethods( [ $iterator, 'getCategoryLinksIterator' ] )
+                               ->getMock();
+
+               $dumpScript->expects( $this->any() )
+                       ->method( 'getCategoryLinksIterator' )
+                       ->willReturnCallback( [ $this, 'getCategoryLinksIterator' ] );
+
+               $dumpScript->expects( $this->once() )
+                       ->method( $iterator )
+                       ->willReturn( [ $result ] );
+
+               $ref = new ReflectionObject( $dumpScript );
+               $processedProperty = $ref->getProperty( 'processed' );
+               $processedProperty->setAccessible( true );
+               $processedProperty->setValue( $dumpScript, $preProcessed );
+
+               $output = fopen( "php://memory", "w+b" );
+               $dbr = wfGetDB( DB_REPLICA );
+               /** @var CategoryChangesAsRdf $dumpScript */
+               $dumpScript->initialize();
+               $dumpScript->getRdf();
+               $dumpScript->$handler( $dbr, $output );
+
+               rewind( $output );
+               $sparql = stream_get_contents( $output );
+               $this->assertFileContains( $testFileName, $sparql );
+
+               $processed = $processedProperty->getValue( $dumpScript );
+               $expectedProcessed = $preProcessed;
+               foreach ( $result as $row ) {
+                       if ( isset( $row->_processed ) ) {
+                               $this->assertArrayHasKey( $row->_processed, $processed,
+                                       "ID {$row->_processed} was not processed!" );
+                               $expectedProcessed[] = $row->_processed;
+                       }
+               }
+               $this->assertArrayEquals( $expectedProcessed, array_keys( $processed ),
+                       'Processed array has wrong items' );
+       }
+
+       public function testUpdateTs() {
+               $dumpScript = new CategoryChangesAsRdf();
+               $dumpScript->initialize();
+               $update = $dumpScript->updateTS( 1503620949 );
+               $outFile = __DIR__ . '/../data/categoriesrdf/updatets.txt';
+               $this->assertFileContains( $outFile, $update );
+       }
+
+}