1<?php
2/**
3 * Generic operation result.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23use MediaWiki\MediaWikiServices;
24
25/**
26 * Generic operation result class
27 * Has warning/error list, boolean status and arbitrary value
28 *
29 * "Good" means the operation was completed with no warnings or errors.
30 *
31 * "OK" means the operation was partially or wholly completed.
32 *
33 * An operation which is not OK should have errors so that the user can be
34 * informed as to what went wrong. Calling the fatal() function sets an error
35 * message and simultaneously switches off the OK flag.
36 *
37 * The recommended pattern for Status objects is to return a Status object
38 * unconditionally, i.e. both on success and on failure -- so that the
39 * developer of the calling code is reminded that the function can fail, and
40 * so that a lack of error-handling will be explicit.
41 *
42 * @newable
43 */
44class Status extends StatusValue {
45	/** @var callable|false */
46	public $cleanCallback = false;
47
48	/** @var MessageLocalizer|null */
49	protected $messageLocalizer;
50
51	/**
52	 * Succinct helper method to wrap a StatusValue
53	 *
54	 * This is is useful when formatting StatusValue objects:
55	 * @code
56	 *     $this->getOutput()->addHtml( Status::wrap( $sv )->getHTML() );
57	 * @endcode
58	 *
59	 * @param StatusValue|Status $sv
60	 * @return Status
61	 */
62	public static function wrap( $sv ) {
63		if ( $sv instanceof static ) {
64			return $sv;
65		}
66
67		$result = new static();
68		$result->ok =& $sv->ok;
69		$result->errors =& $sv->errors;
70		$result->value =& $sv->value;
71		$result->successCount =& $sv->successCount;
72		$result->failCount =& $sv->failCount;
73		$result->success =& $sv->success;
74
75		return $result;
76	}
77
78	/**
79	 * Backwards compatibility logic
80	 *
81	 * @param string $name
82	 * @return mixed
83	 * @throws RuntimeException
84	 */
85	public function __get( $name ) {
86		if ( $name === 'ok' ) {
87			return $this->isOK();
88		}
89		if ( $name === 'errors' ) {
90			return $this->getErrors();
91		}
92
93		throw new RuntimeException( "Cannot get '$name' property." );
94	}
95
96	/**
97	 * Change operation result
98	 * Backwards compatibility logic
99	 *
100	 * @param string $name
101	 * @param mixed $value
102	 * @throws RuntimeException
103	 */
104	public function __set( $name, $value ) {
105		if ( $name === 'ok' ) {
106			$this->setOK( $value );
107		} elseif ( !property_exists( $this, $name ) ) {
108			// Caller is using undeclared ad-hoc properties
109			$this->$name = $value;
110		} else {
111			throw new RuntimeException( "Cannot set '$name' property." );
112		}
113	}
114
115	/**
116	 * Makes this Status object use the given localizer instead of the global one.
117	 * If it is an IContextSource or a ResourceLoaderContext, it will also be used to
118	 * determine the interface language.
119	 * @note This setting does not survive serialization. That's usually for the best
120	 *   (there's no guarantee we'll still have the same localization settings after
121	 *   unserialization); it is the caller's responsibility to set the localizer again
122	 *   if needed.
123	 * @param MessageLocalizer $messageLocalizer
124	 */
125	public function setMessageLocalizer( MessageLocalizer $messageLocalizer ) {
126		$this->messageLocalizer = $messageLocalizer;
127	}
128
129	/**
130	 * Splits this Status object into two new Status objects, one which contains only
131	 * the error messages, and one that contains the warnings, only. The returned array is
132	 * defined as:
133	 * [
134	 *     0 => object(Status) # The Status with error messages, only
135	 *     1 => object(Status) # The Status with warning messages, only
136	 * ]
137	 *
138	 * @return Status[]
139	 */
140	public function splitByErrorType() {
141		list( $errorsOnlyStatus, $warningsOnlyStatus ) = parent::splitByErrorType();
142		// phan/phan#2133?
143		'@phan-var Status $errorsOnlyStatus';
144		'@phan-var Status $warningsOnlyStatus';
145
146		if ( $this->messageLocalizer ) {
147			$errorsOnlyStatus->setMessageLocalizer( $this->messageLocalizer );
148			$warningsOnlyStatus->setMessageLocalizer( $this->messageLocalizer );
149		}
150		$errorsOnlyStatus->cleanCallback =
151			$warningsOnlyStatus->cleanCallback = $this->cleanCallback;
152
153		return [ $errorsOnlyStatus, $warningsOnlyStatus ];
154	}
155
156	/**
157	 * Returns the wrapped StatusValue object
158	 * @return StatusValue
159	 * @since 1.27
160	 */
161	public function getStatusValue() {
162		return $this;
163	}
164
165	/**
166	 * @param array $params
167	 * @return array
168	 */
169	protected function cleanParams( array $params ) {
170		if ( !$this->cleanCallback ) {
171			return $params;
172		}
173		$cleanParams = [];
174		foreach ( $params as $i => $param ) {
175			$cleanParams[$i] = call_user_func( $this->cleanCallback, $param );
176		}
177		return $cleanParams;
178	}
179
180	/**
181	 * Get the error list as a wikitext formatted list
182	 *
183	 * @param string|bool $shortContext A short enclosing context message name, to
184	 *        be used when there is a single error
185	 * @param string|bool $longContext A long enclosing context message name, for a list
186	 * @param string|Language|StubUserLang|null $lang Language to use for processing messages
187	 * @return string
188	 */
189	public function getWikiText( $shortContext = false, $longContext = false, $lang = null ) {
190		$rawErrors = $this->getErrors();
191		if ( count( $rawErrors ) === 0 ) {
192			if ( $this->isOK() ) {
193				$this->fatal( 'internalerror_info',
194					__METHOD__ . " called for a good result, this is incorrect\n" );
195			} else {
196				$this->fatal( 'internalerror_info',
197					__METHOD__ . ": Invalid result object: no error text but not OK\n" );
198			}
199			$rawErrors = $this->getErrors(); // just added a fatal
200		}
201		if ( count( $rawErrors ) === 1 ) {
202			$s = $this->getErrorMessage( $rawErrors[0], $lang )->plain();
203			if ( $shortContext ) {
204				$s = $this->msgInLang( $shortContext, $lang, $s )->plain();
205			} elseif ( $longContext ) {
206				$s = $this->msgInLang( $longContext, $lang, "* $s\n" )->plain();
207			}
208		} else {
209			$errors = $this->getErrorMessageArray( $rawErrors, $lang );
210			foreach ( $errors as &$error ) {
211				$error = $error->plain();
212			}
213			$s = '* ' . implode( "\n* ", $errors ) . "\n";
214			if ( $longContext ) {
215				$s = $this->msgInLang( $longContext, $lang, $s )->plain();
216			} elseif ( $shortContext ) {
217				$s = $this->msgInLang( $shortContext, $lang, "\n$s\n" )->plain();
218			}
219		}
220		return $s;
221	}
222
223	/**
224	 * Get a bullet list of the errors as a Message object.
225	 *
226	 * $shortContext and $longContext can be used to wrap the error list in some text.
227	 * $shortContext will be preferred when there is a single error; $longContext will be
228	 * preferred when there are multiple ones. In either case, $1 will be replaced with
229	 * the list of errors.
230	 *
231	 * $shortContext is assumed to use $1 as an inline parameter: if there is a single item,
232	 * it will not be made into a list; if there are multiple items, newlines will be inserted
233	 * around the list.
234	 * $longContext is assumed to use $1 as a standalone parameter; it will always receive a list.
235	 *
236	 * If both parameters are missing, and there is only one error, no bullet will be added.
237	 *
238	 * @param string|string[]|bool $shortContext A message name or an array of message names.
239	 * @param string|string[]|bool $longContext A message name or an array of message names.
240	 * @param string|Language|StubUserLang|null $lang Language to use for processing messages
241	 * @return Message
242	 */
243	public function getMessage( $shortContext = false, $longContext = false, $lang = null ) {
244		$rawErrors = $this->getErrors();
245		if ( count( $rawErrors ) === 0 ) {
246			if ( $this->isOK() ) {
247				$this->fatal( 'internalerror_info',
248					__METHOD__ . " called for a good result, this is incorrect\n" );
249			} else {
250				$this->fatal( 'internalerror_info',
251					__METHOD__ . ": Invalid result object: no error text but not OK\n" );
252			}
253			$rawErrors = $this->getErrors(); // just added a fatal
254		}
255		if ( count( $rawErrors ) === 1 ) {
256			$s = $this->getErrorMessage( $rawErrors[0], $lang );
257			if ( $shortContext ) {
258				$s = $this->msgInLang( $shortContext, $lang, $s );
259			} elseif ( $longContext ) {
260				$wrapper = new RawMessage( "* \$1\n" );
261				$wrapper->params( $s )->parse();
262				$s = $this->msgInLang( $longContext, $lang, $wrapper );
263			}
264		} else {
265			$msgs = $this->getErrorMessageArray( $rawErrors, $lang );
266			$msgCount = count( $msgs );
267
268			$s = new RawMessage( '* $' . implode( "\n* \$", range( 1, $msgCount ) ) );
269			$s->params( $msgs )->parse();
270
271			if ( $longContext ) {
272				$s = $this->msgInLang( $longContext, $lang, $s );
273			} elseif ( $shortContext ) {
274				$wrapper = new RawMessage( "\n\$1\n", [ $s ] );
275				$wrapper->parse();
276				$s = $this->msgInLang( $shortContext, $lang, $wrapper );
277			}
278		}
279
280		return $s;
281	}
282
283	/**
284	 * Return the message for a single error
285	 *
286	 * The code string can be used a message key with per-language versions.
287	 * If $error is an array, the "params" field is a list of parameters for the message.
288	 *
289	 * @param array|string $error Code string or (key: code string, params: string[]) map
290	 * @param string|Language|null $lang Language to use for processing messages
291	 * @return Message
292	 */
293	protected function getErrorMessage( $error, $lang = null ) {
294		if ( is_array( $error ) ) {
295			if ( isset( $error['message'] ) && $error['message'] instanceof Message ) {
296				$msg = $error['message'];
297			} elseif ( isset( $error['message'] ) && isset( $error['params'] ) ) {
298				$msg = $this->msg( $error['message'], array_map( function ( $param ) {
299					return is_string( $param ) ? wfEscapeWikiText( $param ) : $param;
300				}, $this->cleanParams( $error['params'] ) ) );
301			} else {
302				$msgName = array_shift( $error );
303				$msg = $this->msg( $msgName, array_map( function ( $param ) {
304					return is_string( $param ) ? wfEscapeWikiText( $param ) : $param;
305				}, $this->cleanParams( $error ) ) );
306			}
307		} elseif ( is_string( $error ) ) {
308			$msg = $this->msg( $error );
309		} else {
310			throw new UnexpectedValueException( 'Got ' . get_class( $error ) . ' for key.' );
311		}
312
313		if ( $lang ) {
314			$msg->inLanguage( $lang );
315		}
316		return $msg;
317	}
318
319	/**
320	 * Get the error message as HTML. This is done by parsing the wikitext error message
321	 * @param string|bool $shortContext A short enclosing context message name, to
322	 *        be used when there is a single error
323	 * @param string|bool $longContext A long enclosing context message name, for a list
324	 * @param string|Language|null $lang Language to use for processing messages
325	 * @return string
326	 */
327	public function getHTML( $shortContext = false, $longContext = false, $lang = null ) {
328		$text = $this->getWikiText( $shortContext, $longContext, $lang );
329		$out = MediaWikiServices::getInstance()->getMessageCache()
330			->parse( $text, null, true, true, $lang );
331		return $out instanceof ParserOutput
332			? $out->getText( [ 'enableSectionEditLinks' => false ] )
333			: $out;
334	}
335
336	/**
337	 * Return an array with a Message object for each error.
338	 * @param array $errors
339	 * @param string|Language|null $lang Language to use for processing messages
340	 * @return Message[]
341	 */
342	protected function getErrorMessageArray( $errors, $lang = null ) {
343		return array_map( function ( $e ) use ( $lang ) {
344			return $this->getErrorMessage( $e, $lang );
345		}, $errors );
346	}
347
348	/**
349	 * Get the list of errors (but not warnings)
350	 *
351	 * @return array[] A list in which each entry is an array with a message key as its first element.
352	 *         The remaining array elements are the message parameters.
353	 * @deprecated since 1.25
354	 */
355	public function getErrorsArray() {
356		return $this->getStatusArray( 'error' );
357	}
358
359	/**
360	 * Get the list of warnings (but not errors)
361	 *
362	 * @return array[] A list in which each entry is an array with a message key as its first element.
363	 *         The remaining array elements are the message parameters.
364	 * @deprecated since 1.25
365	 */
366	public function getWarningsArray() {
367		return $this->getStatusArray( 'warning' );
368	}
369
370	/**
371	 * Returns a list of status messages of the given type (or all if false)
372	 *
373	 * @note this handles RawMessage poorly
374	 *
375	 * @param string|bool $type
376	 * @return array[]
377	 */
378	protected function getStatusArray( $type = false ) {
379		$result = [];
380
381		foreach ( $this->getErrors() as $error ) {
382			if ( $type === false || $error['type'] === $type ) {
383				if ( $error['message'] instanceof MessageSpecifier ) {
384					$result[] = array_merge(
385						[ $error['message']->getKey() ],
386						$error['message']->getParams()
387					);
388				} elseif ( $error['params'] ) {
389					$result[] = array_merge( [ $error['message'] ], $error['params'] );
390				} else {
391					$result[] = [ $error['message'] ];
392				}
393			}
394		}
395
396		return $result;
397	}
398
399	/**
400	 * Don't save the callback when serializing, because Closures can't be
401	 * serialized and we're going to clear it in __wakeup anyway.
402	 * Don't save the localizer, because it can be pretty much anything. Restoring it is
403	 * the caller's responsibility (otherwise it will just fall back to the global request context).
404	 * @return array
405	 */
406	public function __sleep() {
407		$keys = array_keys( get_object_vars( $this ) );
408		return array_diff( $keys, [ 'cleanCallback', 'messageLocalizer' ] );
409	}
410
411	/**
412	 * Sanitize the callback parameter on wakeup, to avoid arbitrary execution.
413	 */
414	public function __wakeup() {
415		$this->cleanCallback = false;
416		$this->messageLocalizer = null;
417	}
418
419	/**
420	 * @param string|MessageSpecifier $key
421	 * @param string|string[] ...$params
422	 * @return Message
423	 */
424	private function msg( $key, ...$params ) : Message {
425		if ( $this->messageLocalizer ) {
426			return $this->messageLocalizer->msg( $key, ...$params );
427		} else {
428			return wfMessage( $key, ...$params );
429		}
430	}
431
432	/**
433	 * @param string|MessageSpecifier $key
434	 * @param string|Language|StubUserLang|null $lang
435	 * @param mixed ...$params
436	 * @return Message
437	 */
438	private function msgInLang( $key, $lang, ...$params ) : Message {
439		$msg = $this->msg( $key, ...$params );
440		if ( $lang ) {
441			$msg->inLanguage( $lang );
442		}
443		return $msg;
444	}
445}
446