jenkins-bot has submitted this change and it was merged. ( 
https://gerrit.wikimedia.org/r/402868 )

Change subject: Add API action for editing elements of a form
......................................................................


Add API action for editing elements of a form

Co-authored with Amir Sarabadani.

Bug: T184409
Change-Id: Ice096f53a09b80aa26d2ced342a7986b82d01998
---
M extension.json
M i18n/en.json
M i18n/qqq.json
A src/Api/EditFormElements.php
A src/Api/EditFormElementsRequest.php
A src/Api/EditFormElementsRequestParser.php
A src/Api/EditFormElementsRequestParserResult.php
A src/Api/EditFormElementsSummary.php
A src/Api/Error/FormNotFound.php
A src/Api/Error/ParameterIsNotFormId.php
A src/ChangeOp/ChangeOpEditFormElements.php
M src/DataModel/Form.php
A tests/phpunit/mediawiki/Api/EditFormElementsTest.php
13 files changed, 1,188 insertions(+), 0 deletions(-)

Approvals:
  WMDE-leszek: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/extension.json b/extension.json
index 324d685..4acbf30 100644
--- a/extension.json
+++ b/extension.json
@@ -462,6 +462,10 @@
                "wblexemeaddform": {
                        "class": "Wikibase\\Lexeme\\Api\\AddForm",
                        "factory": 
"Wikibase\\Lexeme\\Api\\AddForm::newFromGlobalState"
+               },
+               "wblexemeeditformelements": {
+                       "class": "Wikibase\\Lexeme\\Api\\EditFormElements",
+                       "factory": 
"Wikibase\\Lexeme\\Api\\EditFormElements::newFromGlobalState"
                }
        },
        "manifest_version": 2
diff --git a/i18n/en.json b/i18n/en.json
index f90a514..5e8a788 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -35,13 +35,20 @@
        "apihelp-wblexemeaddform-param-data": "JSON encoded data for Form i.e. 
representations and grammaticalFeatures",
        "apihelp-wblexemeaddform-param-bot": "Mark this edit as bot. This URL 
flag will only be respected if the user belongs to the group 
\"{{int:group-bot}}\".",
        "apihelp-wblexemeaddform-example-1": "Add Form to the Lexeme with ID 
<samp>$1</samp> having representations <samp>$2</samp> with languages 
<samp>$3</samp> respectively and grammatical features <samp>$4</samp>",
+       "apihelp-wblexemeeditformelements-summary": "Edits representations and 
grammatical features of a Form",
+       "apihelp-wblexemeeditformelements-param-formId": "ID of the Form, e.g. 
L10-F2",
+       "apihelp-wblexemeeditformelements-param-data": "JSON encoded 
representation and grammatical feature data",
+       "apihelp-wblexemeeditformelements-param-bot": "Mark this edit as bot. 
This URL flag will only be respected if the user belongs to the group 
\"{{int:group-bot}}\".",
+       "apihelp-wblexemeeditformelements-example-1": "Edits form with the ID 
<samp>$1</samp> to have representations <samp>$2</samp> in languages 
<samp>$3</samp> respectively, and grammatical features <samp>$4</samp>.",
        "wikibase-lexeme-api-error-parameter-required": "Parameter \"$1\" is 
required",
        "wikibase-lexeme-api-error-parameter-invalid-json-object": "Parameter 
\"$1\" expected to be a valid JSON object, given \"$2\"",
        "wikibase-lexeme-api-error-lexeme-not-found": "No Lexeme with ID \"$1\" 
was found.",
+       "wikibaselexeme-api-error-form-not-found": "No Form with ID \"$1\" was 
found.",
        "wikibase-lexeme-api-error-json-field-required": "Field \"$2\" in 
parameter \"$1\" is required",
        "wikibase-lexeme-api-error-json-field-not-item-id": "Field \"$2\" in 
parameter \"$1\" expected to be na Item Id. Given: \"$3\"",
        "wikibase-lexeme-api-error-json-field-has-wrong-type": "Field \"$2\" in 
parameter \"$1\" expected to be of type \"$3\". Given: \"$4\"",
        "wikibase-lexeme-api-error-parameter-not-lexeme-id": "Parameter \"$1\" 
expected to be a Lexeme ID. Given: \"$2\"",
+       "wikibaselexeme-api-error-parameter-not-form-id": "Parameter \"$1\" 
expected to be a Form ID. Given: \"$2\"",
        "wikibase-lexeme-api-error-representation-text-cannot-be-empty": 
"Representation text cannot be empty",
        "wikibase-lexeme-api-error-representation-language-cannot-be-empty": 
"Representation language cannot be empty",
        "wikibase-lexeme-api-error-form-must-have-at-least-one-representation": 
"Form must have at least one representation",
diff --git a/i18n/qqq.json b/i18n/qqq.json
index 003fa5e..93acecd 100644
--- a/i18n/qqq.json
+++ b/i18n/qqq.json
@@ -40,13 +40,20 @@
        "apihelp-wblexemeaddform-param-data": 
"{{doc-apihelp-param|wblexemeaddform|data}}",
        "apihelp-wblexemeaddform-param-bot": 
"{{doc-apihelp-param|wblexemeaddform|bot}}",
        "apihelp-wblexemeaddform-example-1": 
"{{doc-apihelp-example|wblexemeaddform}}",
+       "apihelp-wblexemeeditformelements-summary": 
"{{doc-apihelp-summary|wblexemeeditformelements}}",
+       "apihelp-wblexemeeditformelements-param-formId": 
"{{doc-apihelp-param|wblexemeeditformelements|formId}}",
+       "apihelp-wblexemeeditformelements-param-data": 
"{{doc-apihelp-param|wblexemeeditformelements|data}}",
+       "apihelp-wblexemeeditformelements-param-bot": 
"{{doc-apihelp-param|wblexemeeditformelements|bot}}",
+       "apihelp-wblexemeeditformelements-example-1": 
"{{doc-apihelp-example|wblexemeeditformelements}}",
        "wikibase-lexeme-api-error-parameter-required": "API error message that 
is being reported when parameter is missing.",
        "wikibase-lexeme-api-error-parameter-invalid-json-object": "API error 
message that is being reported when provided data parameter is not a valid 
JSON",
        "wikibase-lexeme-api-error-lexeme-not-found": "API error message that 
is being reported when Lexeme with provided ID does not 
exist.\n\nParameters:\n* $1 - ID of the lexeme",
+       "wikibaselexeme-api-error-form-not-found": "API error message that is 
being reported when Form with provided ID does not exist.\n\nParameters:\n* $1 
- ID of the form",
        "wikibase-lexeme-api-error-json-field-required": "API error message 
that is being reported when required field in JSON object is not present. 
\n\nParameters:\n* $1 - Name of the parameter that is expected to be JSON 
object. \n* $2 - Path of the field inside of the JSON object (e.g 
\"array-field-name/0/other-field-name\")",
        "wikibase-lexeme-api-error-json-field-not-item-id": "API error message 
that is being reported when field in JSON object expected to contain serialized 
Item ID, but contains something else. \n\nParameters:\n* $1 - Name of the 
parameter that is expected to be JSON object.\n* $2 - Path of the field inside 
of the JSON object (e.g \"array-field-name/0/other-field-name\")\n* $3 - 
Provided value",
        "wikibase-lexeme-api-error-json-field-has-wrong-type": "API error 
message that is being reported when field in JSON object expected to be of 
certain type, but value of another type was provided. \n\nParameters:\n* $1 - 
Name of the parameter that is expected to be JSON object.\n* $2 - Path of the 
field inside of the JSON object (e.g 
\"array-field-name/0/other-field-name\")\n* $3 - Expected type name (e.g. 
string, array, object) \n* $4 - Provided value type name",
        "wikibase-lexeme-api-error-parameter-not-lexeme-id": "API error message 
that is being reported when parameter is expected to contain serialized Lexeme 
ID, but contains something else. \n\nParameters:\n* $1 - Name of the 
parameter\n* $2 - Provided value",
+       "wikibaselexeme-api-error-parameter-not-form-id": "API error message 
that is being reported when parameter is expected to contain serialized Form 
ID, but contains something else. \n\nParameters:\n* $1 - Name of the 
parameter\n* $2 - Provided value",
        "wikibase-lexeme-api-error-representation-text-cannot-be-empty": "API 
error message that is being reported when one of provided representations is 
empty",
        "wikibase-lexeme-api-error-representation-language-cannot-be-empty": 
"API error message that is being reported when one of provided representations' 
language is empty",
        "wikibase-lexeme-api-error-form-must-have-at-least-one-representation": 
"API error message that is being reported when no representations were 
provided",
diff --git a/src/Api/EditFormElements.php b/src/Api/EditFormElements.php
new file mode 100644
index 0000000..5eb2068
--- /dev/null
+++ b/src/Api/EditFormElements.php
@@ -0,0 +1,256 @@
+<?php
+
+namespace Wikibase\Lexeme\Api;
+
+use ApiMain;
+use Wikibase\EditEntityFactory;
+use Wikibase\Lexeme\Api\Error\FormNotFound;
+use Wikibase\Lexeme\DataModel\Form;
+use Wikibase\Lexeme\DataModel\Serialization\FormSerializer;
+use Wikibase\Lib\Store\EntityRevisionLookup;
+use Wikibase\Repo\WikibaseRepo;
+use Wikibase\SummaryFormatter;
+
+/**
+ * @license GPL-2.0+
+ */
+class EditFormElements extends \ApiBase {
+
+       /**
+        * @var EntityRevisionLookup
+        */
+       private $entityRevisionLookup;
+
+       /**
+        * @var EditEntityFactory
+        */
+       private $editEntityFactory;
+
+       /**
+        * @var EditFormElementsRequestParser
+        */
+       private $requestParser;
+
+       /**
+        * @var SummaryFormatter
+        */
+       private $summaryFormatter;
+
+       /**
+        * @var FormSerializer
+        */
+       private $formSerializer;
+
+       public static function newFromGlobalState( ApiMain $mainModule, 
$moduleName ) {
+               $wikibaseRepo = WikibaseRepo::getDefaultInstance();
+
+               $serializerFactory = 
$wikibaseRepo->getBaseDataModelSerializerFactory();
+
+               $formSerializer = new FormSerializer(
+                       $serializerFactory->newTermListSerializer(),
+                       $serializerFactory->newStatementListSerializer()
+               );
+
+               return new self(
+                       $mainModule,
+                       $moduleName,
+                       $wikibaseRepo->getEntityRevisionLookup( 'uncached' ),
+                       $wikibaseRepo->newEditEntityFactory( 
$mainModule->getContext() ),
+                       new EditFormElementsRequestParser( 
$wikibaseRepo->getEntityIdParser() ),
+                       $wikibaseRepo->getSummaryFormatter(),
+                       $formSerializer
+               );
+       }
+
+       public function __construct(
+               ApiMain $mainModule,
+               $moduleName,
+               EntityRevisionLookup $entityRevisionLookup,
+               EditEntityFactory $editEntityFactory,
+               EditFormElementsRequestParser $requestParser,
+               SummaryFormatter $summaryFormatter,
+               FormSerializer $formSerializer
+       ) {
+               parent::__construct( $mainModule, $moduleName );
+
+               $this->entityRevisionLookup = $entityRevisionLookup;
+               $this->editEntityFactory = $editEntityFactory;
+               $this->requestParser = $requestParser;
+               $this->summaryFormatter = $summaryFormatter;
+               $this->formSerializer = $formSerializer;
+       }
+
+       public function execute() {
+               $params = $this->extractRequestParams();
+               $parserResult = $this->requestParser->parse( $params );
+
+               if ( $parserResult->hasErrors() ) {
+                       //TODO: Increase stats counter on failure
+                       // `wikibase.repo.api.errors.total` counter
+                       $this->dieStatus( $parserResult->asFatalStatus() );
+               }
+
+               $request = $parserResult->getRequest();
+               $formId = $request->getFormId();
+
+               $latestRevision = 0;
+               $formRevision = $this->entityRevisionLookup->getEntityRevision(
+                       $formId,
+                       $latestRevision,
+                       EntityRevisionLookup::LATEST_FROM_MASTER
+               );
+
+               if ( $formRevision === null ) {
+                       $error = new FormNotFound( $formId );
+                       $this->dieWithError( $error->asApiMessage() );
+               }
+               $form = $formRevision->getEntity();
+
+               $changeOp = $request->getChangeOp();
+               $summary = new EditFormElementsSummary();
+               // TODO: We can not use summary in the changeop, we should
+               $changeOp->apply( $form );
+
+               $status = $this->saveForm( $form, $summary, 
$formRevision->getRevisionId(), $params );
+
+               if ( !$status->isGood() ) {
+                       $this->dieStatus( $status );
+               }
+
+               $this->generateResponse( $form );
+       }
+
+       /**
+        * @param Form $form
+        * @param EditFormElementsSummary $summary
+        * @param int $baseRevisionId
+        * @param array $params
+        * @return \Status
+        */
+       private function saveForm(
+               Form $form,
+               EditFormElementsSummary $summary,
+               $baseRevisionId,
+               array $params
+       ) {
+               $editEntity = $this->editEntityFactory->newEditEntity(
+                       $this->getUser(),
+                       $form->getId(),
+                       $baseRevisionId
+               );
+
+               // TODO: bot flag should probably be part of the request
+               $flags = EDIT_UPDATE;
+               if ( isset( $params['bot'] ) && $params['bot'] && 
$this->getUser()->isAllowed( 'bot' ) ) {
+                       $flags |= EDIT_FORCE_BOT;
+               }
+
+               $tokenThatDoesNotNeedChecking = false;
+               return $editEntity->attemptSave(
+                       $form,
+                       $this->summaryFormatter->formatSummary( $summary ),
+                       $flags,
+                       $tokenThatDoesNotNeedChecking
+               );
+       }
+
+       /**
+        * @param Form $form
+        */
+       private function generateResponse( Form $form ) {
+               $apiResult = $this->getResult();
+
+               $serializedForm = $this->formSerializer->serialize( $form );
+               unset( $serializedForm['claims'] );
+
+               // TODO: Do we really need `success` property in response?
+               $apiResult->addValue( null, 'success', 1 );
+               $apiResult->addValue( null, 'form', $serializedForm );
+       }
+
+       /**
+        * @see ApiBase::getAllowedParams
+        */
+       protected function getAllowedParams() {
+               return [
+                       'formId' => [
+                               self::PARAM_TYPE => 'string',
+                               self::PARAM_REQUIRED => true,
+                       ],
+                       'data' => [
+                               self::PARAM_TYPE => 'text',
+                               self::PARAM_REQUIRED => true,
+                       ],
+                       'bot' => [
+                               self::PARAM_TYPE => 'boolean',
+                               self::PARAM_DFLT => false,
+                       ]
+               ];
+       }
+
+       /**
+        * @see ApiBase::isWriteMode()
+        */
+       public function isWriteMode() {
+               return true;
+       }
+
+       /**
+        * @see ApiBase::needsToken()
+        */
+       public function needsToken() {
+               return 'csrf';
+       }
+
+       /**
+        * @see ApiBase::mustBePosted()
+        */
+       public function mustBePosted() {
+               return true;
+       }
+
+       protected function getExamplesMessages() {
+               $formId = 'L12';
+               $exampleData = [
+                       'representations' => [
+                               [ 'representation' => 'color', 'language' => 
'en-US' ],
+                               [ 'representation' => 'colour', 'language' => 
'en-GB' ],
+                       ],
+                       'grammaticalFeatures' => [
+                               'Q1', 'Q2'
+                       ]
+               ];
+
+               $query = http_build_query( [
+                       'action' => $this->getModuleName(),
+                       'formId' => $formId,
+                       'data' => json_encode( $exampleData )
+               ] );
+
+               $languages = array_map( function ( $r ) {
+                       return $r['language'];
+               }, $exampleData['representations'] );
+               $representations = array_map( function ( $r ) {
+                       return $r['representation'];
+               }, $exampleData['representations'] );
+
+               $representationsText = $this->getLanguage()->commaList( 
$representations );
+               $languagesText = $this->getLanguage()->commaList( $languages );
+               $grammaticalFeaturesText = $this->getLanguage()->commaList( 
$exampleData['grammaticalFeatures'] );
+
+               $exampleMessage = new \Message(
+                       'apihelp-wblexemeeditformelements-example-1',
+                       [
+                               $formId,
+                               $representationsText,
+                               $languagesText,
+                               $grammaticalFeaturesText
+                       ]
+               );
+
+               return [
+                       $query => $exampleMessage
+               ];
+       }
+
+}
diff --git a/src/Api/EditFormElementsRequest.php 
b/src/Api/EditFormElementsRequest.php
new file mode 100644
index 0000000..ffdbcd9
--- /dev/null
+++ b/src/Api/EditFormElementsRequest.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Wikibase\Lexeme\Api;
+
+use Wikibase\DataModel\Entity\ItemId;
+use Wikibase\DataModel\Term\TermList;
+use Wikibase\Lexeme\ChangeOp\ChangeOpEditFormElements;
+use Wikibase\Lexeme\DataModel\FormId;
+
+/**
+ * @license GPL-2.0+
+ */
+class EditFormElementsRequest {
+
+       /**
+        * @var FormId
+        */
+       private $formId;
+
+       /**
+        * @var TermList
+        */
+       private $representations;
+
+       /**
+        * @var ItemId[]
+        */
+       private $grammaticalFeatures;
+
+       public function __construct(
+               FormId $formId,
+               TermList $representations,
+               array $grammaticalFeatures
+       ) {
+               $this->formId = $formId;
+               $this->representations = $representations;
+               $this->grammaticalFeatures = $grammaticalFeatures;
+       }
+
+       public function getChangeOp() {
+               return new ChangeOpEditFormElements( $this->representations, 
$this->grammaticalFeatures );
+       }
+
+       public function getFormId() {
+               return $this->formId;
+       }
+
+}
diff --git a/src/Api/EditFormElementsRequestParser.php 
b/src/Api/EditFormElementsRequestParser.php
new file mode 100644
index 0000000..2a6c1cd
--- /dev/null
+++ b/src/Api/EditFormElementsRequestParser.php
@@ -0,0 +1,243 @@
+<?php
+
+namespace Wikibase\Lexeme\Api;
+
+use Wikibase\DataModel\Entity\EntityIdParser;
+use Wikibase\DataModel\Entity\EntityIdParsingException;
+use Wikibase\DataModel\Entity\ItemId;
+use Wikibase\DataModel\Entity\ItemIdParser;
+use Wikibase\DataModel\Term\Term;
+use Wikibase\DataModel\Term\TermList;
+use Wikibase\Lexeme\Api\Error\ApiError;
+use Wikibase\Lexeme\Api\Error\FormMustHaveAtLeastOneRepresentation;
+use Wikibase\Lexeme\Api\Error\JsonFieldHasWrongType;
+use Wikibase\Lexeme\Api\Error\JsonFieldIsNotAnItemId;
+use Wikibase\Lexeme\Api\Error\JsonFieldIsRequired;
+use Wikibase\Lexeme\Api\Error\ParameterIsNotAJsonObject;
+use Wikibase\Lexeme\Api\Error\ParameterIsNotFormId;
+use Wikibase\Lexeme\Api\Error\ParameterIsRequired;
+use Wikibase\Lexeme\Api\Error\RepresentationLanguageCanNotBeEmpty;
+use Wikibase\Lexeme\Api\Error\RepresentationsMustHaveUniqueLanguage;
+use Wikibase\Lexeme\Api\Error\RepresentationTextCanNotBeEmpty;
+use Wikibase\Lexeme\DataModel\FormId;
+
+/**
+ * @license GPL-2.0+
+ */
+class EditFormElementsRequestParser {
+
+       const PARAM_DATA = 'data';
+
+       const PARAM_FORM_ID = 'formId';
+
+       /**
+        * @var ItemIdParser
+        */
+       private $itemIdParser;
+
+       /**
+        * @var EntityIdParser
+        */
+       private $entityIdParser;
+
+       public function __construct( EntityIdParser $entityIdParser ) {
+               $this->itemIdParser = new ItemIdParser();
+               $this->entityIdParser = $entityIdParser;
+       }
+
+       public function parse( array $params ) {
+               //TODO: validate language. How?
+               //TODO: validate if all grammatical features exist
+               $errors = $this->validateRequiredFieldsPresent( $params );
+               if ( $errors ) {
+                       return 
EditFormElementsRequestParserResult::newWithErrors( $errors );
+               }
+
+               $data = json_decode( $params[self::PARAM_DATA] );
+               if ( !is_object( $data ) ) {
+                       return 
EditFormElementsRequestParserResult::newWithErrors(
+                               [
+                                       new ParameterIsNotAJsonObject( 
self::PARAM_DATA, $params[self::PARAM_DATA] )
+                               ]
+                       );
+               }
+
+               $errors = $this->validateDataStructure( $data );
+               if ( $errors ) {
+                       return 
EditFormElementsRequestParserResult::newWithErrors( $errors );
+               }
+
+               $formId = $this->parseFormId( $params['formId'], $errors );
+               $representations = $this->parseRepresentations( 
$data->representations, $errors );
+               $grammaticalFeatures = $this->parseGrammaticalFeatures( 
$data->grammaticalFeatures, $errors );
+
+               if ( $errors ) {
+                       return 
EditFormElementsRequestParserResult::newWithErrors( $errors );
+               }
+
+               return EditFormElementsRequestParserResult::newWithRequest(
+                       new EditFormElementsRequest(
+                               $formId,
+                               $representations,
+                               $grammaticalFeatures
+                       )
+               );
+       }
+
+       /**
+        * @param $id
+        * @param array &$errors
+        * @return FormId|null
+        */
+       private function parseFormId( $id, array &$errors ) {
+               try {
+                       $formId = $this->entityIdParser->parse( $id );
+               } catch ( EntityIdParsingException $e ) {
+                       $errors[] = new ParameterIsNotFormId( 
self::PARAM_FORM_ID, $id );
+                       return null;
+               }
+
+               if ( $formId->getEntityType() !== 'form' ) {
+                       $errors[] = new ParameterIsNotFormId( 
self::PARAM_FORM_ID, $id );
+                       return null;
+               }
+
+               return $formId;
+       }
+
+       /**
+        * @param \stdClass[] $givenRepresentations
+        * @param ApiError[] &$errors
+        * @return TermList
+        */
+       private function parseRepresentations( array $givenRepresentations, 
array &$errors ) {
+               if ( empty( $givenRepresentations ) ) {
+                       $errors[] = new FormMustHaveAtLeastOneRepresentation( 
self::PARAM_DATA, [ 'representations' ] );
+               }
+
+               //FIXME: Array may contain representation with empty text (or 
untrimmed) which won't be added
+               $result = [];
+
+               foreach ( $givenRepresentations as $index => $el ) {
+                       $incomplete = false;
+
+                       if ( !property_exists( $el, 'representation' ) ) {
+                               $errors[] = new JsonFieldIsRequired(
+                                       self::PARAM_DATA,
+                                       [ 'representations', $index, 
'representation' ]
+                               );
+                               $incomplete = true;
+                       } elseif ( empty( $el->representation ) ) {
+                               $errors[] = new RepresentationTextCanNotBeEmpty(
+                                       self::PARAM_DATA,
+                                       [ 'representations', $index, 
'representation' ]
+                               );
+                               $incomplete = true;
+                       }
+
+                       if ( !property_exists( $el, 'language' ) ) {
+                               $errors[] = new JsonFieldIsRequired(
+                                       self::PARAM_DATA,
+                                       [ 'representations', $index, 'language' 
]
+                               );
+                               $incomplete = true;
+                       } elseif ( empty( $el->language ) ) {
+                               $errors[] = new 
RepresentationLanguageCanNotBeEmpty(
+                                       self::PARAM_DATA,
+                                       [ 'representations', $index, 'language' 
]
+                               );
+                               $incomplete = true;
+                       }
+
+                       if ( $incomplete ) {
+                               continue;
+                       }
+
+                       if ( isset( $result[$el->language] ) ) {
+                               $errors[] = new 
RepresentationsMustHaveUniqueLanguage(
+                                       self::PARAM_DATA,
+                                       [ 'representations', $index, 'language' 
],
+                                       $el->language
+                               );
+                       }
+
+                       $result[$el->language] = $el->representation;
+               }
+
+               $terms = [];
+               foreach ( $result as $language => $representation ) {
+                       $terms[] = new Term( $language, $representation );
+               }
+
+               return new TermList( $terms );
+       }
+
+       /**
+        * @param string[] $data
+        * @param ApiError[] $errors
+        * @return ItemId[]
+        */
+       private function parseGrammaticalFeatures( array $data, array &$errors 
) {
+               $features = [];
+
+               foreach ( $data as $index => $featureId ) {
+                       try {
+                               $id = $this->itemIdParser->parse( $featureId );
+                       } catch ( EntityIdParsingException $e ) {
+                               $errors[] = new JsonFieldIsNotAnItemId(
+                                       self::PARAM_DATA,
+                                       [ 'grammaticalFeatures', $index ],
+                                       $featureId
+                               );
+                               continue;
+                       }
+
+                       $features[] = $id;
+               }
+
+               return $features;
+       }
+
+       private function validateDataStructure( $data ) {
+               $errors = [];
+
+               if ( !property_exists( $data, 'representations' ) ) {
+                       $errors[] = new JsonFieldIsRequired( self::PARAM_DATA, 
[ 'representations' ] );
+               } elseif ( !is_array( $data->representations ) ) {
+                       $errors[] = new JsonFieldHasWrongType(
+                               self::PARAM_DATA,
+                               [ 'representations' ],
+                               'array',
+                               gettype( $data->representations )
+                       );
+               }
+
+               if ( !property_exists( $data, 'grammaticalFeatures' ) ) {
+                       $errors[] = new JsonFieldIsRequired( self::PARAM_DATA, 
[ 'grammaticalFeatures' ] );
+               } elseif ( !is_array( $data->grammaticalFeatures ) ) {
+                       $errors[] = new JsonFieldHasWrongType(
+                               self::PARAM_DATA,
+                               [ 'grammaticalFeatures' ],
+                               'array',
+                               gettype( $data->grammaticalFeatures )
+                       );
+               }
+
+               return $errors;
+       }
+
+       private function validateRequiredFieldsPresent( array $params ) {
+               $errors = [];
+
+               if ( !array_key_exists( self::PARAM_FORM_ID, $params ) ) {
+                       $errors[] = new ParameterIsRequired( 
self::PARAM_FORM_ID );
+               }
+
+               if ( !array_key_exists( self::PARAM_DATA, $params ) ) {
+                       $errors[] = new ParameterIsRequired( self::PARAM_DATA );
+               }
+
+               return $errors;
+       }
+
+}
diff --git a/src/Api/EditFormElementsRequestParserResult.php 
b/src/Api/EditFormElementsRequestParserResult.php
new file mode 100644
index 0000000..f9ff9ea
--- /dev/null
+++ b/src/Api/EditFormElementsRequestParserResult.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Wikibase\Lexeme\Api;
+
+use Wikibase\Lexeme\Api\Error\ApiError;
+use Wikimedia\Assert\Assert;
+
+/**
+ * @license GPL-2.0+
+ */
+class EditFormElementsRequestParserResult {
+
+       /**
+        * @var EditFormElementsRequest|null
+        */
+       private $request;
+
+       /**
+        * @var ApiError[]
+        */
+       private $errors;
+
+       public static function newWithRequest( EditFormElementsRequest $request 
) {
+               return new self( $request, [] );
+       }
+
+       /**
+        * @param ApiError[] $errors
+        * @return EditFormElementsRequestParserResult
+        */
+       public static function newWithErrors( array $errors ) {
+               return new self( null, $errors );
+       }
+
+       /**
+        * @param EditFormElementsRequest|null $request
+        * @param ApiError[] $errors
+        */
+       private function __construct( EditFormElementsRequest $request = null, 
array $errors ) {
+               Assert::parameterElementType( ApiError::class, $errors, 
'$errors' );
+               $this->request = $request;
+               $this->errors = $errors;
+       }
+
+       /**
+        * @return EditFormElementsRequest
+        */
+       public function getRequest() {
+               if ( $this->errors ) {
+                       throw new \LogicException(
+                               'There have been errors when parsing the 
request. Call getErrors to handle them'
+                       );
+               }
+
+               return $this->request;
+       }
+
+       public function hasErrors() {
+               return !empty( $this->errors );
+       }
+
+       /**
+        * @return \Status
+        */
+       public function asFatalStatus() {
+               if ( !$this->hasErrors() ) {
+                       throw new \LogicException( 'Succesful result can not be 
converted to fatal status' );
+               }
+
+               $status = \Status::newGood();
+               foreach ( $this->errors as $error ) {
+                       $status->fatal( $error->asApiMessage() );
+               }
+               return $status;
+       }
+
+}
diff --git a/src/Api/EditFormElementsSummary.php 
b/src/Api/EditFormElementsSummary.php
new file mode 100644
index 0000000..134d886
--- /dev/null
+++ b/src/Api/EditFormElementsSummary.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Wikibase\Lexeme\Api;
+
+use Wikibase\Lib\FormatableSummary;
+
+/**
+ * @license GPL-2.0+
+ */
+class EditFormElementsSummary implements FormatableSummary {
+
+       public function getUserSummary() {
+               return null;
+       }
+
+       public function getLanguageCode() {
+               return null;
+       }
+
+       public function getMessageKey() {
+               /** @see "wikibase-lexeme-summary-set-form" message */
+               return 'set-form';
+       }
+
+       public function getCommentArgs() {
+               return [];
+       }
+
+       public function getAutoSummaryArgs() {
+               return [];
+       }
+
+}
diff --git a/src/Api/Error/FormNotFound.php b/src/Api/Error/FormNotFound.php
new file mode 100644
index 0000000..b3363ab
--- /dev/null
+++ b/src/Api/Error/FormNotFound.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Wikibase\Lexeme\Api\Error;
+
+use ApiMessage;
+use Message;
+use Wikibase\Lexeme\DataModel\FormId;
+
+/**
+ * @license GPL-2.0+
+ */
+class FormNotFound implements ApiError {
+
+       /**
+        * @var FormId
+        */
+       private $formId;
+
+       public function __construct( FormId $formId ) {
+               $this->formId = $formId;
+       }
+
+       /**
+        * @return ApiMessage
+        */
+       public function asApiMessage() {
+               $message = new Message(
+                       'wikibaselexeme-api-error-form-not-found',
+                       [ $this->formId->serialize() ]
+               );
+               return new ApiMessage( $message, 'not-found' );
+       }
+
+}
diff --git a/src/Api/Error/ParameterIsNotFormId.php 
b/src/Api/Error/ParameterIsNotFormId.php
new file mode 100644
index 0000000..182e6b2
--- /dev/null
+++ b/src/Api/Error/ParameterIsNotFormId.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Wikibase\Lexeme\Api\Error;
+
+use ApiMessage;
+
+/**
+ * @license GPL-2.0+
+ */
+class ParameterIsNotFormId implements ApiError {
+
+       /**
+        * @var string
+        */
+       private $parameterName;
+
+       /**
+        * @var string
+        */
+       private $given;
+
+       /**
+        * @param string $parameterName
+        * @param string $given
+        */
+       public function __construct( $parameterName, $given ) {
+               $this->parameterName = $parameterName;
+               $this->given = $given;
+       }
+
+       /**
+        * @see ApiError::asApiMessage()
+        */
+       public function asApiMessage() {
+               // Parameter "$1" expected to be a valid Form ID (ex. 
"L10-F1"), given "$2"
+               $message = new \Message(
+                       'wikibaselexeme-api-error-parameter-not-form-id',
+                       [ $this->parameterName, $this->given ]
+               );
+               return new ApiMessage( $message, 'bad-request' );
+       }
+
+}
diff --git a/src/ChangeOp/ChangeOpEditFormElements.php 
b/src/ChangeOp/ChangeOpEditFormElements.php
new file mode 100644
index 0000000..b31ed8a
--- /dev/null
+++ b/src/ChangeOp/ChangeOpEditFormElements.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Wikibase\Lexeme\ChangeOp;
+
+use ValueValidators\Result;
+use Wikibase\DataModel\Entity\EntityDocument;
+use Wikibase\DataModel\Entity\ItemId;
+use Wikibase\DataModel\Term\TermList;
+use Wikibase\Lexeme\DataModel\Form;
+use Wikibase\Repo\ChangeOp\ChangeOp;
+use Wikibase\Repo\Store\EntityPermissionChecker;
+use Wikibase\Summary;
+use Wikimedia\Assert\Assert;
+
+/**
+ * @license GPL-2.0+
+ */
+class ChangeOpEditFormElements implements ChangeOp {
+
+       /**
+        * @var TermList
+        */
+       private $representations;
+
+       /**
+        * @var ItemId[]
+        */
+       private $grammaticalFeatures;
+
+       public function __construct( TermList $representations, array 
$grammaticalFeatures ) {
+               $this->representations = $representations;
+               $this->grammaticalFeatures = $grammaticalFeatures;
+       }
+
+       public function validate( EntityDocument $entity ) {
+               // TODO: Should this be also a change op applicable on Lexeme 
entities
+               // (e.g. when used in wbeditentity)?
+               Assert::parameterType( Form::class, $entity, '$entity' );
+
+               return Result::newSuccess();
+       }
+
+       public function apply( EntityDocument $entity, Summary $summary = null 
) {
+               // TODO: Should this be also a change op applicable on Lexeme 
entities
+               // (e.g. when used in wbeditentity)?
+               Assert::parameterType( Form::class, $entity, '$entity' );
+
+               /** @var Form $entity */
+               $entity->setRepresentations( $this->representations );
+               $entity->setGrammaticalFeatures( $this->grammaticalFeatures );
+       }
+
+       public function getActions() {
+               return [ EntityPermissionChecker::ACTION_EDIT ];
+       }
+
+}
diff --git a/src/DataModel/Form.php b/src/DataModel/Form.php
index b9b5879..efc4b44 100644
--- a/src/DataModel/Form.php
+++ b/src/DataModel/Form.php
@@ -101,6 +101,10 @@
                return $this->representations;
        }
 
+       public function setRepresentations( TermList $representations ) {
+               $this->representations = $representations;
+       }
+
        /**
         * @return ItemId[]
         */
diff --git a/tests/phpunit/mediawiki/Api/EditFormElementsTest.php 
b/tests/phpunit/mediawiki/Api/EditFormElementsTest.php
new file mode 100644
index 0000000..b6e3fb7
--- /dev/null
+++ b/tests/phpunit/mediawiki/Api/EditFormElementsTest.php
@@ -0,0 +1,375 @@
+<?php
+
+namespace Wikibase\Lexeme\Tests\MediaWiki\Api;
+
+use ApiMessage;
+use ApiUsageException;
+use Wikibase\DataModel\Entity\ItemId;
+use Wikibase\Lexeme\DataModel\FormId;
+use Wikibase\Lexeme\DataModel\Lexeme;
+use Wikibase\Lexeme\DataModel\LexemeId;
+use Wikibase\Lexeme\Tests\DataModel\NewForm;
+use Wikibase\Lexeme\Tests\DataModel\NewLexeme;
+use Wikibase\Repo\Tests\Api\WikibaseApiTestCase;
+use Wikibase\Repo\WikibaseRepo;
+
+/**
+ * @covers \Wikibase\Lexeme\Api\EditFormElements
+ *
+ * @license GPL-2.0+
+ *
+ * @group Database
+ * @group medium
+ */
+class EditFormElementsTest extends WikibaseApiTestCase {
+
+       /**
+        * @dataProvider provideInvalidParams
+        */
+       public function testGivenInvalidParameter_errorIsReturned(
+               array $params,
+               array $expectedError
+       ) {
+               $this->setContentLang( 'qqq' );
+               $params = array_merge(
+                       [ 'action' => 'wblexemeeditformelements' ],
+                       $params
+               );
+
+               try {
+                       $this->doApiRequestWithToken( $params );
+                       $this->fail( 'No API error was raised' );
+               } catch ( ApiUsageException $e ) {
+                       /** @var ApiMessage $message */
+                       $message = $e->getMessageObject();
+
+                       $this->assertInstanceOf( ApiMessage::class, $message );
+                       $this->assertEquals( $expectedError['message-key'], 
$message->getKey(), 'Wrong message codes' );
+                       $this->assertEquals(
+                               $expectedError['message-parameters'],
+                               $message->getParams(),
+                               'Wrong message parameters'
+                       );
+                       $this->assertEquals(
+                               $expectedError['api-error-code'],
+                               $message->getApiCode(),
+                               'Wrong api code'
+                       );
+                       $this->assertEquals(
+                               $expectedError['api-error-data'],
+                               $message->getApiData(),
+                               'Wrong api data'
+                       );
+               }
+       }
+
+       private function getDataParam( array $dataToUse = [] ) {
+               $simpleData = [
+                       'representations' => [
+                               [
+                                       'language' => 'en',
+                                       'representation' => 'colour'
+                               ]
+                       ],
+                       'grammaticalFeatures' => [ 'Q17' ],
+               ];
+
+               return json_encode( array_merge( $simpleData, $dataToUse ) );
+       }
+
+       public function provideInvalidParams() {
+               return [
+                       'no formId param' => [
+                               [ 'data' => $this->getDataParam() ],
+                               [
+                                       'message-key' => 
'apierror-missingparam',
+                                       'message-parameters' => [ 'formId' ],
+                                       'api-error-code' => 'noformId',
+                                       'api-error-data' => []
+                               ],
+                       ],
+                       'no data param' => [
+                               [ 'formId' => 'L1-F1' ],
+                               [
+                                       'message-key' => 
'apierror-missingparam',
+                                       'message-parameters' => [ 'data' ],
+                                       'api-error-code' => 'nodata',
+                                       'api-error-data' => []
+                               ],
+                       ],
+                       'invalid form ID (random string not ID)' => [
+                               [ 'formId' => 'foo', 'data' => 
$this->getDataParam() ],
+                               [
+                                       'message-key' => 
'wikibaselexeme-api-error-parameter-not-form-id',
+                                       'message-parameters' => [ 'formId', 
'foo' ],
+                                       'api-error-code' => 'bad-request',
+                                       'api-error-data' => []
+                               ]
+                       ],
+                       'data not a well-formed JSON object' => [
+                               [ 'formId' => 'L1-F1', 'data' => '{foo' ],
+                               [
+                                       'message-key' => 
'wikibase-lexeme-api-error-parameter-invalid-json-object',
+                                       'message-parameters' => [ 'data', 
'{foo' ],
+                                       'api-error-code' => 'bad-request',
+                                       'api-error-data' => []
+                               ],
+                       ],
+                       'Form is not found' => [
+                               [ 'formId' => 'L999-F1', 'data' => 
$this->getDataParam() ],
+                               [
+                                       'message-key' => 
'wikibaselexeme-api-error-form-not-found',
+                                       'message-parameters' => [ 'L999-F1' ],
+                                       'api-error-code' => 'not-found',
+                                       'api-error-data' => []
+                               ],
+                       ],
+               ];
+       }
+
+       public function 
testGivenOtherRepresentations_changesRepresentationsOfForm() {
+               $form = NewForm::havingId( 'F1' )->andRepresentation( 'en', 
'goat' )->build();
+               $lexeme = NewLexeme::havingId( 'L1' )->withForm( $form 
)->build();
+
+               $this->saveLexeme( $lexeme );
+
+               $params = [
+                       'action' => 'wblexemeeditformelements',
+                       'formId' => 'L1-F1',
+                       'data' => json_encode( [
+                               'representations' => [
+                                       [ 'language' => 'en', 'representation' 
=> 'goadth' ],
+                               ],
+                               'grammaticalFeatures' => [],
+                       ] ),
+               ];
+
+               $this->doApiRequestWithToken( $params );
+
+               $lexeme = $this->getLexeme( 'L1' );
+
+               $form = $lexeme->getForms()->getById( new FormId( 'L1-F1' ) );
+               $this->assertEquals( 'goadth', 
$form->getRepresentations()->getByLanguage( 'en' )->getText() );
+       }
+
+       public function 
testGivenRepresentationNotThere_representationIsRemoved() {
+               $form = NewForm::havingId( 'F1' )
+                       ->andRepresentation( 'en', 'colour' )
+                       ->andRepresentation( 'en-us', 'color' )
+                       ->build();
+               $lexeme = NewLexeme::havingId( 'L1' )->withForm( $form 
)->build();
+
+               $this->saveLexeme( $lexeme );
+
+               $params = [
+                       'action' => 'wblexemeeditformelements',
+                       'formId' => 'L1-F1',
+                       'data' => json_encode( [
+                               'representations' => [
+                                       [ 'language' => 'en', 'representation' 
=> 'colour' ],
+                               ],
+                               'grammaticalFeatures' => [],
+                       ] ),
+               ];
+
+               $this->doApiRequestWithToken( $params );
+
+               $lexeme = $this->getLexeme( 'L1' );
+
+               $form = $lexeme->getForms()->getById( new FormId( 'L1-F1' ) );
+               $this->assertEquals( 'colour', 
$form->getRepresentations()->getByLanguage( 'en' )->getText() );
+               $this->assertFalse( 
$form->getRepresentations()->hasTermForLanguage( 'en-us' ) );
+       }
+
+       public function 
testGivenRepresentationForNewLanguage_representationIsAdded() {
+               $form = NewForm::havingId( 'F1' )
+                       ->andRepresentation( 'en', 'colour' )
+                       ->build();
+               $lexeme = NewLexeme::havingId( 'L1' )->withForm( $form 
)->build();
+
+               $this->saveLexeme( $lexeme );
+
+               $params = [
+                       'action' => 'wblexemeeditformelements',
+                       'formId' => 'L1-F1',
+                       'data' => json_encode( [
+                               'representations' => [
+                                       [ 'language' => 'en', 'representation' 
=> 'colour' ],
+                                       [ 'language' => 'en-us', 
'representation' => 'color' ],
+                               ],
+                               'grammaticalFeatures' => [],
+                       ] ),
+               ];
+
+               $this->doApiRequestWithToken( $params );
+
+               $lexeme = $this->getLexeme( 'L1' );
+
+               $form = $lexeme->getForms()->getById( new FormId( 'L1-F1' ) );
+               $this->assertEquals( 'colour', 
$form->getRepresentations()->getByLanguage( 'en' )->getText() );
+               $this->assertEquals( 'color', 
$form->getRepresentations()->getByLanguage( 'en-us' )->getText() );
+       }
+
+       public function 
testGivenOtherGrammaticalFeatures_grammaticalFeaturesAreChanged() {
+               $form = NewForm::havingId( 'F1' )
+                       ->andGrammaticalFeature( 'Q123' )
+                       ->andRepresentation( 'en', 'goat' )
+                       ->build();
+               $lexeme = NewLexeme::havingId( 'L1' )->withForm( $form 
)->build();
+
+               $this->saveLexeme( $lexeme );
+
+               $params = [
+                       'action' => 'wblexemeeditformelements',
+                       'formId' => 'L1-F1',
+                       'data' => json_encode( [
+                               'representations' => [
+                                       [ 'language' => 'en', 'representation' 
=> 'goat' ],
+                               ],
+                               'grammaticalFeatures' => [ 'Q321' ],
+                       ] ),
+               ];
+
+               $this->doApiRequestWithToken( $params );
+
+               $lexeme = $this->getLexeme( 'L1' );
+
+               $form = $lexeme->getForms()->getById( new FormId( 'L1-F1' ) );
+               $this->assertEquals( [ new ItemId( 'Q321' ) ], 
$form->getGrammaticalFeatures() );
+       }
+
+       public function 
testGivenNewGrammaticalFeature_grammaticalFeatureIsAdded() {
+               $form = NewForm::havingId( 'F1' )
+                       ->andGrammaticalFeature( 'Q123' )
+                       ->andRepresentation( 'en', 'goat' )
+                       ->build();
+               $lexeme = NewLexeme::havingId( 'L1' )->withForm( $form 
)->build();
+
+               $this->saveLexeme( $lexeme );
+
+               $params = [
+                       'action' => 'wblexemeeditformelements',
+                       'formId' => 'L1-F1',
+                       'data' => json_encode( [
+                               'representations' => [
+                                       [ 'language' => 'en', 'representation' 
=> 'goat' ],
+                               ],
+                               'grammaticalFeatures' => [ 'Q123', 'Q678' ],
+                       ] ),
+               ];
+
+               $this->doApiRequestWithToken( $params );
+
+               $lexeme = $this->getLexeme( 'L1' );
+
+               $form = $lexeme->getForms()->getById( new FormId( 'L1-F1' ) );
+               $this->assertEquals(
+                       [ new ItemId( 'Q123' ), new ItemId( 'Q678' ) ],
+                       $form->getGrammaticalFeatures()
+               );
+       }
+
+       public function 
testGivenNoGrammaticalFeature_grammaticalFeatureIsRemoved() {
+               $form = NewForm::havingId( 'F1' )
+                       ->andGrammaticalFeature( 'Q123' )
+                       ->andRepresentation( 'en', 'goat' )
+                       ->build();
+               $lexeme = NewLexeme::havingId( 'L1' )->withForm( $form 
)->build();
+
+               $this->saveLexeme( $lexeme );
+
+               $params = [
+                       'action' => 'wblexemeeditformelements',
+                       'formId' => 'L1-F1',
+                       'data' => json_encode( [
+                               'representations' => [
+                                       [ 'language' => 'en', 'representation' 
=> 'goat' ],
+                               ],
+                               'grammaticalFeatures' => [],
+                       ] ),
+               ];
+
+               $this->doApiRequestWithToken( $params );
+
+               $lexeme = $this->getLexeme( 'L1' );
+
+               $form = $lexeme->getForms()->getById( new FormId( 'L1-F1' ) );
+               $this->assertEmpty( $form->getGrammaticalFeatures() );
+       }
+
+       // TODO: test summary once its set!
+
+       public function testGivenFormEdited_responseContainsSuccessMarker() {
+               $form = NewForm::havingId( 'F1' )
+                       ->andGrammaticalFeature( 'Q123' )
+                       ->andRepresentation( 'en', 'goat' )
+                       ->build();
+               $lexeme = NewLexeme::havingId( 'L1' )->withForm( $form 
)->build();
+
+               $this->saveLexeme( $lexeme );
+
+               $params = [
+                       'action' => 'wblexemeeditformelements',
+                       'formId' => 'L1-F1',
+                       'data' => $this->getDataParam()
+               ];
+
+               list( $result, ) = $this->doApiRequestWithToken( $params );
+
+               $this->assertSame( 1, $result['success'] );
+       }
+
+       public function testGivenFormEdited_responseContainsSavedFormData() {
+               $form = NewForm::havingId( 'F1' )
+                       ->andRepresentation( 'en', 'colour' )
+                       ->build();
+               $lexeme = NewLexeme::havingId( 'L1' )->withForm( $form 
)->build();
+
+               $this->saveLexeme( $lexeme );
+
+               $params = [
+                       'action' => 'wblexemeeditformelements',
+                       'formId' => 'L1-F1',
+                       'data' => json_encode( [
+                               'representations' => [
+                                       [ 'language' => 'en', 'representation' 
=> 'colour' ],
+                                       [ 'language' => 'en-us', 
'representation' => 'color' ],
+                               ],
+                               'grammaticalFeatures' => [ 'Q321' ],
+                       ] ),
+               ];
+
+               list( $result, ) = $this->doApiRequestWithToken( $params );
+
+               $this->assertEquals(
+                       [
+                               'id' => 'L1-F1',
+                               'representations' => [
+                                       'en' => [ 'language' => 'en', 'value' 
=> 'colour' ],
+                                       'en-us' => [ 'language' => 'en-us', 
'value' => 'color' ],
+                               ],
+                               'grammaticalFeatures' => [ 'Q321' ],
+                       ],
+                       $result['form']
+               );
+       }
+
+       // TODO: test API response contains the revision ID
+
+       private function saveLexeme( Lexeme $lexeme ) {
+               $store = WikibaseRepo::getDefaultInstance()->getEntityStore();
+
+               $store->saveEntity( $lexeme, self::class, $this->getMock( 
\User::class ) );
+       }
+
+       /**
+        * @param string $id
+        *
+        * @return Lexeme|null
+        */
+       private function getLexeme( $id ) {
+               $lookup = WikibaseRepo::getDefaultInstance()->getEntityLookup();
+               return $lookup->getEntity( new LexemeId( $id ) );
+       }
+
+}

-- 
To view, visit https://gerrit.wikimedia.org/r/402868
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: Ice096f53a09b80aa26d2ced342a7986b82d01998
Gerrit-PatchSet: 11
Gerrit-Project: mediawiki/extensions/WikibaseLexeme
Gerrit-Branch: master
Gerrit-Owner: WMDE-leszek <[email protected]>
Gerrit-Reviewer: Jonas Kress (WMDE) <[email protected]>
Gerrit-Reviewer: Thiemo Kreuz (WMDE) <[email protected]>
Gerrit-Reviewer: WMDE-leszek <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to