setHook( 'vote', [ self::class, 'renderVoteTag' ] ); } public static function onParserPreSaveTransformComplete( Parser $parser, string &$text ) { // workaround for https://phabricator.wikimedia.org/T319221 $sigText = $parser->getUserSig(RequestContext::getMain()->getUser()); $datetime = getFormattedTimestamp($parser); $text = preg_replace('/(?)(?)~~~~/', "$sigText $datetime", $text); // NOTE: this regex obviously matches false positives, however since // it's quite uncommon for ~~~~ to occur in wiki pages this is deemed good enough // until T319221 is addressed and this workaround is no longer necessary } // Render 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; $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); $user = Title::newFromText('User:' . rtrim(array_shift($parts), ':')); $vote = strtolower(array_shift($parts)); $comment = array_shift($parts); return ['date' => $date, 'userTitle' => $user, 'user' => $user->getText(), '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, ], '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(''); // dirty hack to work around https://phabricator.wikimedia.org/T319216 return str_replace('type="radio"', 'type="radio" required', $form->getHTML(false)); } } // 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 "$error"; } function getFormattedTimestamp($parser) { // This function exists solely because of the onParserPreSaveTransformComplete workaround for https://phabricator.wikimedia.org/T319221. // It is supposed to mirror the behavior of https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/core/+/refs/tags/1.38.4/includes/parser/Parser.php#4575. $ts = $parser->mOptions->getTimestamp(); $timestamp = MWTimestamp::getLocalInstance( $ts ); $ts = $timestamp->format( 'YmdHis' ); $tzMsg = $timestamp->getTimezoneMessage()->inContentLanguage()->text(); return $parser->getContentLanguage()->timeanddate( $ts, false, false ) . " ($tzMsg)"; }