diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php
index b893bc9e147..abbc62c7f6c 100644
--- a/includes/AutoLoader.php
+++ b/includes/AutoLoader.php
@@ -134,6 +134,7 @@ class AutoLoader {
'MediaWiki\\Edit\\' => __DIR__ . '/edit/',
'MediaWiki\\EditPage\\' => __DIR__ . '/editpage/',
'MediaWiki\\Linker\\' => __DIR__ . '/linker/',
+ 'MediaWiki\\Message\\' => __DIR__ . '/Message',
'MediaWiki\\Permissions\\' => __DIR__ . '/Permissions/',
'MediaWiki\\Preferences\\' => __DIR__ . '/preferences/',
'MediaWiki\\Rest\\' => __DIR__ . '/Rest/',
@@ -143,6 +144,7 @@ class AutoLoader {
'MediaWiki\\Sparql\\' => __DIR__ . '/sparql/',
'MediaWiki\\Storage\\' => __DIR__ . '/Storage/',
'MediaWiki\\Tidy\\' => __DIR__ . '/tidy/',
+ 'Wikimedia\\Message\\' => __DIR__ . '/libs/Message/',
'Wikimedia\\ParamValidator\\' => __DIR__ . '/libs/ParamValidator/',
'Wikimedia\\Services\\' => __DIR__ . '/libs/services/',
];
diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php
index 6013aafc9ea..c89fa4a23f0 100644
--- a/includes/MediaWikiServices.php
+++ b/includes/MediaWikiServices.php
@@ -18,6 +18,7 @@ use MediaWiki\Block\BlockManager;
use MediaWiki\Block\BlockRestrictionStore;
use MediaWiki\FileBackend\FSFile\TempFSFileFactory;
use MediaWiki\Http\HttpRequestFactory;
+use Wikimedia\Message\IMessageFormatterFactory;
use MediaWiki\Page\MovePageFactory;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Preferences\PreferencesFactory;
@@ -709,6 +710,14 @@ class MediaWikiServices extends ServiceContainer {
return $this->getService( 'MessageCache' );
}
+ /**
+ * @since 1.34
+ * @return IMessageFormatterFactory
+ */
+ public function getMessageFormatterFactory() {
+ return $this->getService( 'MessageFormatterFactory' );
+ }
+
/**
* @since 1.28
* @return MimeAnalyzer
diff --git a/includes/Message/MessageFormatterFactory.php b/includes/Message/MessageFormatterFactory.php
new file mode 100644
index 00000000000..101224a6a82
--- /dev/null
+++ b/includes/Message/MessageFormatterFactory.php
@@ -0,0 +1,29 @@
+textFormatters[$langCode] ) ) {
+ $this->textFormatters[$langCode] = new TextFormatter( $langCode );
+ }
+ return $this->textFormatters[$langCode];
+ }
+}
diff --git a/includes/Message/TextFormatter.php b/includes/Message/TextFormatter.php
new file mode 100644
index 00000000000..f5eeb16f247
--- /dev/null
+++ b/includes/Message/TextFormatter.php
@@ -0,0 +1,74 @@
+langCode = $langCode;
+ }
+
+ /**
+ * Allow the Message class to be mocked in tests by constructing objects in
+ * a protected method.
+ *
+ * @internal
+ * @param string $key
+ * @return Message
+ */
+ protected function createMessage( $key ) {
+ return new Message( $key );
+ }
+
+ public function getLangCode() {
+ return $this->langCode;
+ }
+
+ private static function convertParam( MessageParam $param ) {
+ if ( $param instanceof ListParam ) {
+ $convertedElements = [];
+ foreach ( $param->getValue() as $element ) {
+ $convertedElements[] = self::convertParam( $element );
+ }
+ return Message::listParam( $convertedElements, $param->getListType() );
+ } elseif ( $param instanceof MessageParam ) {
+ if ( $param->getType() === ParamType::TEXT ) {
+ return $param->getValue();
+ } else {
+ return [ $param->getType() => $param->getValue() ];
+ }
+ } else {
+ throw new \InvalidArgumentException( 'Invalid message parameter type' );
+ }
+ }
+
+ public function format( MessageValue $mv ) {
+ $message = $this->createMessage( $mv->getKey() );
+ foreach ( $mv->getParams() as $param ) {
+ $message->params( self::convertParam( $param ) );
+ }
+ $message->inLanguage( $this->langCode );
+ return $message->text();
+ }
+}
diff --git a/includes/Rest/LocalizedHttpException.php b/includes/Rest/LocalizedHttpException.php
new file mode 100644
index 00000000000..10d3a4034a6
--- /dev/null
+++ b/includes/Rest/LocalizedHttpException.php
@@ -0,0 +1,11 @@
+getKey(), $code );
+ }
+}
diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php
index b30726415e1..7000bd3b79d 100644
--- a/includes/ServiceWiring.php
+++ b/includes/ServiceWiring.php
@@ -52,6 +52,8 @@ use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Linker\LinkRendererFactory;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
+use Wikimedia\Message\IMessageFormatterFactory;
+use MediaWiki\Message\MessageFormatterFactory;
use MediaWiki\Page\MovePageFactory;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Preferences\PreferencesFactory;
@@ -350,6 +352,11 @@ return [
);
},
+ 'MessageFormatterFactory' =>
+ function ( MediaWikiServices $services ) : IMessageFormatterFactory {
+ return new MessageFormatterFactory();
+ },
+
'MimeAnalyzer' => function ( MediaWikiServices $services ) : MimeAnalyzer {
$logger = LoggerFactory::getInstance( 'Mime' );
$mainConfig = $services->getMainConfig();
diff --git a/includes/libs/Message/IMessageFormatterFactory.php b/includes/libs/Message/IMessageFormatterFactory.php
new file mode 100644
index 00000000000..337ea82436d
--- /dev/null
+++ b/includes/libs/Message/IMessageFormatterFactory.php
@@ -0,0 +1,18 @@
+type = ParamType::LIST;
+ $this->listType = $listType;
+ $this->value = [];
+ foreach ( $elements as $element ) {
+ if ( $element instanceof MessageParam ) {
+ $this->value[] = $element;
+ } elseif ( is_scalar( $element ) ) {
+ $this->value[] = new TextParam( ParamType::TEXT, $element );
+ } else {
+ throw new \InvalidArgumentException(
+ 'ListParam elements must be MessageParam or scalar' );
+ }
+ }
+ }
+
+ /**
+ * Get the type of the list
+ *
+ * @return string One of the ListType constants
+ */
+ public function getListType() {
+ return $this->listType;
+ }
+
+ public function dump() {
+ $contents = '';
+ foreach ( $this->value as $element ) {
+ $contents .= $element->dump();
+ }
+ return "<{$this->type} listType=\"{$this->listType}\">$contents{$this->type}>";
+ }
+}
diff --git a/includes/libs/Message/ListType.php b/includes/libs/Message/ListType.php
new file mode 100644
index 00000000000..60f3a822332
--- /dev/null
+++ b/includes/libs/Message/ListType.php
@@ -0,0 +1,22 @@
+type;
+ }
+
+ /**
+ * Get the input value of the parameter
+ *
+ * @return int|float|string|array
+ */
+ public function getValue() {
+ return $this->value;
+ }
+
+ /**
+ * Dump the object for testing/debugging
+ *
+ * @return string
+ */
+ abstract public function dump();
+}
diff --git a/includes/libs/Message/MessageValue.php b/includes/libs/Message/MessageValue.php
new file mode 100644
index 00000000000..13b97f224ae
--- /dev/null
+++ b/includes/libs/Message/MessageValue.php
@@ -0,0 +1,258 @@
+key = $key;
+ $this->params = [];
+ $this->params( ...$params );
+ }
+
+ /**
+ * Get the message key
+ *
+ * @return string
+ */
+ public function getKey() {
+ return $this->key;
+ }
+
+ /**
+ * Get the parameter array
+ *
+ * @return MessageParam[]
+ */
+ public function getParams() {
+ return $this->params;
+ }
+
+ /**
+ * Chainable mutator which adds text parameters and MessageParam parameters
+ *
+ * @param mixed ...$values Scalar or MessageParam values
+ * @return MessageValue
+ */
+ public function params( ...$values ) {
+ foreach ( $values as $value ) {
+ if ( $value instanceof MessageParam ) {
+ $this->params[] = $value;
+ } else {
+ $this->params[] = new TextParam( ParamType::TEXT, $value );
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Chainable mutator which adds text parameters with a common type
+ *
+ * @param string $type One of the ParamType constants
+ * @param mixed ...$values Scalar values
+ * @return MessageValue
+ */
+ public function textParamsOfType( $type, ...$values ) {
+ foreach ( $values as $value ) {
+ $this->params[] = new TextParam( $type, $value );
+ }
+ return $this;
+ }
+
+ /**
+ * Chainable mutator which adds list parameters with a common type
+ *
+ * @param string $listType One of the ListType constants
+ * @param array ...$values Each value should be an array of list items.
+ * @return MessageValue
+ */
+ public function listParamsOfType( $listType, ...$values ) {
+ foreach ( $values as $value ) {
+ $this->params[] = new ListParam( $listType, $value );
+ }
+ return $this;
+ }
+
+ /**
+ * Chainable mutator which adds parameters of type text.
+ *
+ * @param string ...$values
+ * @return MessageValue
+ */
+ public function textParams( ...$values ) {
+ return $this->textParamsOfType( ParamType::TEXT, ...$values );
+ }
+
+ /**
+ * Chainable mutator which adds numeric parameters
+ *
+ * @param mixed ...$values
+ * @return MessageValue
+ */
+ public function numParams( ...$values ) {
+ return $this->textParamsOfType( ParamType::NUM, ...$values );
+ }
+
+ /**
+ * Chainable mutator which adds parameters which are a duration specified
+ * in seconds. This is similar to timePeriodParams() except that the result
+ * will be more verbose.
+ *
+ * @param int|float ...$values
+ * @return MessageValue
+ */
+ public function longDurationParams( ...$values ) {
+ return $this->textParamsOfType( ParamType::DURATION_LONG, ...$values );
+ }
+
+ /**
+ * Chainable mutator which adds parameters which are a time period in seconds.
+ * This is similar to durationParams() except that the result will be more
+ * compact.
+ *
+ * @param int|float ...$values
+ * @return MessageValue
+ */
+ public function shortDurationParams( ...$values ) {
+ return $this->textParamsOfType( ParamType::DURATION_SHORT, ...$values );
+ }
+
+ /**
+ * Chainable mutator which adds parameters which are an expiry timestamp
+ * as used in the MediaWiki database schema.
+ *
+ * @param string ...$values
+ * @return MessageValue
+ */
+ public function expiryParams( ...$values ) {
+ return $this->textParamsOfType( ParamType::EXPIRY, ...$values );
+ }
+
+ /**
+ * Chainable mutator which adds parameters which are a number of bytes.
+ *
+ * @param int ...$values
+ * @return MessageValue
+ */
+ public function sizeParams( ...$values ) {
+ return $this->textParamsOfType( ParamType::SIZE, ...$values );
+ }
+
+ /**
+ * Chainable mutator which adds parameters which are a number of bits per
+ * second.
+ *
+ * @param int|float ...$values
+ * @return MessageValue
+ */
+ public function bitrateParams( ...$values ) {
+ return $this->textParamsOfType( ParamType::BITRATE, ...$values );
+ }
+
+ /**
+ * Chainable mutator which adds parameters of type "raw".
+ *
+ * @param mixed ...$values
+ * @return MessageValue
+ */
+ public function rawParams( ...$values ) {
+ return $this->textParamsOfType( ParamType::RAW, ...$values );
+ }
+
+ /**
+ * Chainable mutator which adds parameters of type "plaintext".
+ */
+ public function plaintextParams( ...$values ) {
+ return $this->textParamsOfType( ParamType::PLAINTEXT, ...$values );
+ }
+
+ /**
+ * Chainable mutator which adds comma lists. Each comma list is an array of
+ * list elements, and each list element is either a MessageParam or a
+ * string. String parameters are converted to parameters of type "text".
+ *
+ * The list parameters thus created are formatted as a comma-separated list,
+ * or some local equivalent.
+ *
+ * @param (MessageParam|string)[] ...$values
+ * @return MessageValue
+ */
+ public function commaListParams( ...$values ) {
+ return $this->listParamsOfType( ListType::COMMA, ...$values );
+ }
+
+ /**
+ * Chainable mutator which adds semicolon lists. Each semicolon list is an
+ * array of list elements, and each list element is either a MessageParam
+ * or a string. String parameters are converted to parameters of type
+ * "text".
+ *
+ * The list parameters thus created are formatted as a semicolon-separated
+ * list, or some local equivalent.
+ *
+ * @param (MessageParam|string)[] ...$values
+ * @return MessageValue
+ */
+ public function semicolonListParams( ...$values ) {
+ return $this->listParamsOfType( ListType::SEMICOLON, ...$values );
+ }
+
+ /**
+ * Chainable mutator which adds pipe lists. Each pipe list is an array of
+ * list elements, and each list element is either a MessageParam or a
+ * string. String parameters are converted to parameters of type "text".
+ *
+ * The list parameters thus created are formatted as a pipe ("|") -separated
+ * list, or some local equivalent.
+ *
+ * @param (MessageParam|string)[] ...$values
+ * @return MessageValue
+ */
+ public function pipeListParams( ...$values ) {
+ return $this->listParamsOfType( ListType::PIPE, ...$values );
+ }
+
+ /**
+ * Chainable mutator which adds text lists. Each text list is an array of
+ * list elements, and each list element is either a MessageParam or a
+ * string. String parameters are converted to parameters of type "text".
+ *
+ * The list parameters thus created, when formatted, are joined as in natural
+ * language. In English, this means a comma-separated list, with the last
+ * two elements joined with "and".
+ *
+ * @param (MessageParam|string)[] ...$values
+ * @return MessageValue
+ */
+ public function textListParams( ...$values ) {
+ return $this->listParamsOfType( ListType::AND, ...$values );
+ }
+
+ /**
+ * Dump the object for testing/debugging
+ *
+ * @return string
+ */
+ public function dump() {
+ $contents = '';
+ foreach ( $this->params as $param ) {
+ $contents .= $param->dump();
+ }
+ return '' .
+ $contents . '';
+ }
+}
diff --git a/includes/libs/Message/ParamType.php b/includes/libs/Message/ParamType.php
new file mode 100644
index 00000000000..890ef38ee41
--- /dev/null
+++ b/includes/libs/Message/ParamType.php
@@ -0,0 +1,47 @@
+type = $type;
+ $this->value = $value;
+ }
+
+ public function dump() {
+ return "<{$this->type}>" . htmlspecialchars( $this->value ) . "{$this->type}>";
+ }
+}
diff --git a/tests/phpunit/includes/Message/TextFormatterTest.php b/tests/phpunit/includes/Message/TextFormatterTest.php
new file mode 100644
index 00000000000..233810fe1b5
--- /dev/null
+++ b/tests/phpunit/includes/Message/TextFormatterTest.php
@@ -0,0 +1,59 @@
+createTextFormatter( 'fr' );
+ $this->assertSame( 'fr', $formatter->getLangCode() );
+ }
+
+ public function testFormatBitrate() {
+ $formatter = $this->createTextFormatter( 'en' );
+ $mv = ( new MessageValue( 'test' ) )->bitrateParams( 100, 200 );
+ $result = $formatter->format( $mv );
+ $this->assertSame( 'test 100 bps 200 bps', $result );
+ }
+
+ public function testFormatList() {
+ $formatter = $this->createTextFormatter( 'en' );
+ $mv = ( new MessageValue( 'test' ) )->commaListParams( [
+ 'a',
+ new TextParam( ParamType::BITRATE, 100 ),
+ ] );
+ $result = $formatter->format( $mv );
+ $this->assertSame( 'test a, 100 bps $2', $result );
+ }
+}
+
+class FakeMessage extends Message {
+ public function fetchMessage() {
+ return "{$this->getKey()} $1 $2";
+ }
+}
diff --git a/tests/phpunit/includes/libs/Message/MessageValueTest.php b/tests/phpunit/includes/libs/Message/MessageValueTest.php
new file mode 100644
index 00000000000..04dfa4e7e99
--- /dev/null
+++ b/tests/phpunit/includes/libs/Message/MessageValueTest.php
@@ -0,0 +1,219 @@
+',
+ ],
+ [
+ [ 'a' ],
+ 'a'
+ ],
+ [
+ [ new TextParam( ParamType::BITRATE, 100 ) ],
+ '100'
+ ],
+ ];
+ }
+
+ /** @dataProvider provideConstruct */
+ public function testConstruct( $input, $expected ) {
+ $mv = new MessageValue( 'key', $input );
+ $this->assertSame( $expected, $mv->dump() );
+ }
+
+ public function testGetKey() {
+ $mv = new MessageValue( 'key' );
+ $this->assertSame( 'key', $mv->getKey() );
+ }
+
+ public function testParams() {
+ $mv = new MessageValue( 'key' );
+ $mv->params( 1, 'x' );
+ $mv2 = $mv->params( new TextParam( ParamType::BITRATE, 100 ) );
+ $this->assertSame(
+ '1x100',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testTextParamsOfType() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->textParamsOfType( ParamType::BITRATE, 1, 2 );
+ $this->assertSame( '' .
+ '12' .
+ '',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testListParamsOfType() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->listParamsOfType( ListType::COMMA, [ 'a' ], [ 'b', 'c' ] );
+ $this->assertSame( '' .
+ 'a
' .
+ 'bc
' .
+ '',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testTextParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->textParams( 'a', 'b' );
+ $this->assertSame( '' .
+ 'a' .
+ 'b' .
+ '',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testNumParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->numParams( 1, 2 );
+ $this->assertSame( '' .
+ '1' .
+ '2' .
+ '',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testLongDurationParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->longDurationParams( 1, 2 );
+ $this->assertSame( '' .
+ '1' .
+ '2' .
+ '',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testShortDurationParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->shortDurationParams( 1, 2 );
+ $this->assertSame( '' .
+ '1' .
+ '2' .
+ '',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testExpiryParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->expiryParams( 1, 2 );
+ $this->assertSame( '' .
+ '1' .
+ '2' .
+ '',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testSizeParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->sizeParams( 1, 2 );
+ $this->assertSame( '' .
+ '1' .
+ '2' .
+ '',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testBitrateParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->bitrateParams( 1, 2 );
+ $this->assertSame( '' .
+ '1' .
+ '2' .
+ '',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testRawParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->rawParams( 1, 2 );
+ $this->assertSame( '' .
+ '1' .
+ '2' .
+ '',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testPlaintextParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->plaintextParams( 1, 2 );
+ $this->assertSame( '' .
+ '1' .
+ '2' .
+ '',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testCommaListParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->commaListParams( [ 'a', 'b' ] );
+ $this->assertSame( '' .
+ '' .
+ 'ab' .
+ '
',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function tesSemicolonListParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->semicolonListParams( [ 'a', 'b' ] );
+ $this->assertSame( '' .
+ '' .
+ 'ab' .
+ '
',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testPipeListParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->pipeListParams( [ 'a', 'b' ] );
+ $this->assertSame( '' .
+ '' .
+ 'ab' .
+ '
',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+
+ public function testTextListParams() {
+ $mv = new MessageValue( 'key' );
+ $mv2 = $mv->textListParams( [ 'a', 'b' ] );
+ $this->assertSame( '' .
+ '' .
+ 'ab' .
+ '
',
+ $mv->dump() );
+ $this->assertSame( $mv, $mv2 );
+ }
+}