public static function renderVoteTag( $input, array $args, Parser $parser, PPFrame $frame ) {
$msg = function (...$args) use ($parser) {
// wfMessage() uses the interface language by default ... but since
// MediaWiki caches the rendered page we of course want to use the
// page content language instead. This closure is just for convenience.
return wfMessage(...$args)->inLanguage($parser->getTargetLanguage());
};
$addErrorCategory = function () use ($parser) {
$parser->addTrackingCategory('pages-with-vote-errors-category', $parser->getTitle());
};
$voteClosed = false;
$endDate = null;
$end = $args['end'] ?? null;
unset($args['end']);
$cancel = $args['cancel'] ?? null;
unset($args['cancel']);
$text = '';
if ($cancel !== null) {
$voteClosed = $msg('vote-cancelled');
} else if ($end) {
$endDate = DateTime::createFromFormat('Y-m-d', $end);
if (!$endDate) {
$addErrorCategory();
return formatError($msg('vote-error-end'));
}
$endDate->setTime(23, 59);
$now = new Datetime();
$tz = date_default_timezone_get();
if ($now > $endDate) {
$voteClosed = $msg('vote-ended', $endDate->format('jS F Y, H:i') . " ($tz)");
} else {
$text .= $msg('vote-will-end', $endDate->format('jS F Y, H:i') . " ($tz)");
$parser->getOutput()->updateCacheExpiry($endDate->getTimestamp() - $now->getTimestamp());
}
}
if (!empty($args)) {
$addErrorCategory();
return formatError($msg('vote-error-unknown-attribute', array_keys($args)[0]));
}
if (!str_ends_with($input, "\n")) {
$addErrorCategory();
return formatError($msg('vote-error-missing-newline'));
}
// Parse the passed text into $lines.
// Sidenote: We parse and render in two steps because we need both:
// * lookahead (gray out votes that are superseded), as well as
// * lookbehind (prefix "I changed my mind" for superseding votes).
$lines = [];
foreach (explode("\n", $input) as $line) {
if (str_starts_with($line, '* ')) {
$line = substr($line, 2);
$vote = self::parseVote($line);
if (!$vote['date']) {
array_push($lines, ['error' => $msg('vote-error-invalid-date'), 'line' => $line]);
continue;
}
if ($endDate && $vote['date'] > $endDate) {
array_push($lines, ['error' => $msg('vote-error-too-late'), 'line' => $line]);
continue;
}
if (!in_array($vote['vote'], self::$options)) {
array_push($lines, ['error' => $msg('vote-error-invalid-vote', '(YES, NO, ABSTAIN)'), 'line' => $line]);
continue;
}
array_push($lines, $vote);
continue;
}
array_push($lines, ['raw' => $line . "\n"]);
}
// Render the parsed $lines into $voteText.
$voteText = '';
$votes = [];
foreach ($lines as $idx => $line) {
if (array_key_exists('raw', $line)) {
$voteText .= $parser->recursiveTagParse($line['raw']);
} elseif (array_key_exists('error', $line)) {
$addErrorCategory();
$voteText .= '* ' . formatError($line['error'] . ':') . " " . $line['line'] . "\n";
} else {
$user = $line['user'];
$vote = $line['vote'];
$voteText .= '* ';
$class = '';
for ($i = $idx + 1; $i < count($lines); $i++) {
if (($lines[$i]['user'] ?? null) == $user) {
$class = 'uncounted-vote';
}
}
$voteText .= "";
if (array_key_exists($user, $votes)) {
if ($votes[$user] == $vote) {
$voteText .= "(" . $msg('vote-repeated') . ') ';
} else {
$voteText .= $msg('vote-changed') . ', ';
}
}
$votes[$user] = $vote;
$voteText .= $msg('vote-option-' . $vote) . ' ';
if ($line['comment']) {
$voteText .= $parser->recursiveTagParse($line['comment']);
}
$userLink = $parser->getLinkRenderer()->makeLink($line['userTitle'], $user);
$voteText .= " --$userLink ". $line['date']->format('H:i, j F Y');
$voteText .= "\n";
}
}
$voteCounts = ['yes' => 0, 'no' => 0, 'abstain' => 0];
foreach (array_values($votes) as $vote) {
$voteCounts[$vote] += 1;
}
if (!$voteClosed && $parser->getOutput()->getExtensionData('vote-open')) {
$voteClosed = formatError($msg('vote-error-one-vote-per-page'));
$addErrorCategory();
}
$parser->getOutput()->appendExtensionData('vote-open', true);
$text .= '';
if (array_sum(array_values($voteCounts)) == 0 && !$voteClosed) {
$text .= $msg('vote-call-to-vote');
} else {
$text .= $msg('vote-tally', $voteCounts['yes'], $voteCounts['no'], $voteCounts['abstain']);
}
$text .= '
';
$text .= $voteText;
if ($voteClosed) {
$text .= $voteClosed;
} else {
$text .= self::votingFormHtml($parser, $votes);
$parser->addTrackingCategory('pages-with-open-votes-category', $parser->getTitle());
}
$parser->getOutput()->addModules(['ext.vote-css']);
return $text;
}
private static function parseVote($line) {
$date = DateTime::createFromFormat('Y-m-d H:i', substr($line, 0, strlen('2022-10-03 10:00')));
$parts = explode(' ', substr($line, strlen('2022-10-03 10:00 ')), 3);
$userTitle = Title::newFromText('User:' . rtrim(array_shift($parts), ':'));
$user = $userTitle ? $userTitle->getText() : null;
$vote = strtolower(array_shift($parts));
$comment = array_shift($parts);
return ['date' => $date, 'userTitle' => $userTitle, 'user' => $user, 'vote' => $vote, 'comment' => $comment];
}
private static function votingFormHtml($parser, $votes) {
$previewMode = $parser->getRevisionId() == null;
$formDescriptor = [
'vote' => [
'type' => 'radio',
'options' => array_reduce(self::$options, function($result, $opt) use ($parser) {
$result[wfMessage('vote-option-' . $opt)->inLanguage($parser->getTargetLanguage())->parse()] = $opt;
return $result;
}),
// ideally we would set 'required' => true here but unfortunately that doesn't work
// see https://phabricator.wikimedia.org/T319216
'disabled' => $previewMode,
],
'comment' => [
'type' => 'textarea',
'rows' => 3,
'label' => wfMessage('vote-comment')->inLanguage($parser->getTargetLanguage()),
'disabled' => $previewMode,
'cssclass' => 'vote-comment-textarea',
],
'page' => [
'type' => 'hidden',
'name' => 'page',
'default' => $parser->getTitle()
]
];
$parser->getOutput()->addModules(['ext.vote-js']);
$form = new HTMLForm( $formDescriptor );
if ($previewMode)
$form->suppressDefaultSubmit();
$form->mFieldData = [];
$form
->setId('vote-form')
->setTitle(Title::newFromText('Special:InsertVote'))
->setSubmitText( 'Submit' )
->setAutocomplete('off')
->setDisplayFormat('div')
->addHeaderHtml('' . wfMessage('vote-add-your-vote')->inLanguage($parser->getTargetLanguage()) . '
')
->addFooterHtml('');
// The MobileFrontend extension requires the mw-ui-button CSS class for
// buttons to look good. Xml::submitButton however only adds that CSS
// class when the global variable $wgUseMediaWikiUIEverywhere is set to
// true ... and MobileFrontend for some reason doesn't set that variable,
// so we just set it here ... ¯\_(ツ)_/¯
global $wgUseMediaWikiUIEverywhere;
$wgUseMediaWikiUIEverywhere = true;
$html = $form->getHTML(false);
// dirty hack to work around https://phabricator.wikimedia.org/T319216
$html = str_replace('type="radio"', 'type="radio" required', $html);
return $html;
}
}
// We need a special page to receive the submitted votes.
class SpecialInsertVote extends UnlistedSpecialPage {
function __construct() {
parent::__construct( 'InsertVote' );
}
function execute( $par ) {
$request = $this->getRequest();
$output = $this->getOutput();
$page = $request->getVal('page');
if ($page == null) {
$output->setStatusCode(400);
$output->prepareErrorPage('missing page parameter');
return;
}
$vote = $request->getVal('wpvote');
if ($vote == null) {
$output->setStatusCode(400);
$output->prepareErrorPage('missing wpvote parameter');
return;
}
$comment = $request->getText('wpcomment');
$services = MediaWikiServices::getInstance();
$permissionManager = $services->getPermissionManager();
if ($this->getUser()->isAnon()) {
$loginPageTitle = Title::newFromText('Special:UserLogin');
$this->getOutput()->redirect($loginPageTitle->getFullURL([
'returnto' => 'Special:InsertVote',
'returntoquery' => $request->getRawPostString()
]));
return;
}
$title = Title::newFromText($page);
$permissionErrors = $permissionManager->getPermissionErrors('edit', $this->getUser(), $title);
if (!empty($permissionErrors)) {
$output->prepareErrorPage("You are not allowed to edit this page.");
$output->setStatusCode(403);
return;
}
$content = WikiPage::factory($title)->getContent();
if ($content == null) {
$output->prepareErrorPage("The page no longer exists.");
$output->setStatusCode(500);
return;
}
$content = ContentHandler::getContentText($content);
$date = date('Y-m-d H:i');
$username = str_replace(' ', '_', $this->getUser()->getName());
$comment = str_replace("\n", ' ', $comment);
$newLine = "* $date $username: " . strtoupper($vote);
if ($comment)
$newLine .= ' ' . $comment;
// not using preg_replace because we don't want references like $1 in the comment to be interpreted
preg_match(',^?$,m', $content, $matches, PREG_OFFSET_CAPTURE);
if (!$matches) {
$output->prepareErrorPage("could not find tag, maybe someone has removed the vote in the meantime?");
$output->setStatusCode(500);
return;
}
$offset = $matches[0][1];
$content = substr($content, 0, $offset) . $newLine . "\n" . substr($content, $offset);
$page = WikiPage::factory($title);
$pageLang = $page->getLanguage() ?? $services->getContentLanguage();
$status = $page->doUserEditContent(
new WikitextContent($content),
$this->getUser(),
wfMessage('vote-editmsg-' . $vote)->inLanguage($pageLang), EDIT_UPDATE, false, ['vote']
);
if ($status->isOK()) {
$this->getOutput()->redirect($title->getFullURL());
} else {
$output->prepareErrorPage($status->getHTML());
$output->setStatusCode(500);
}
}
}
function formatError($error) {
// The CSS class is provided by MediaWiki.
return "