diff options
author | Martin Fischer <martin@push-f.com> | 2022-10-03 20:58:18 +0200 |
---|---|---|
committer | Martin Fischer <martin@push-f.com> | 2022-10-04 20:35:21 +0200 |
commit | 9066e158af51282c623bf71671e41b1893365b77 (patch) | |
tree | ffa6b280d6c04579594eab74dc7f7336f239b782 |
initial commit
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | Vote.php | 291 | ||||
-rw-r--r-- | extension.json | 43 | ||||
-rw-r--r-- | i18n/en.json | 28 | ||||
-rw-r--r-- | resources/optional.js | 51 |
5 files changed, 434 insertions, 0 deletions
@@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Martin Fischer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Vote.php b/Vote.php new file mode 100644 index 0000000..1d1c302 --- /dev/null +++ b/Vote.php @@ -0,0 +1,291 @@ +<?php +use MediaWiki\MediaWikiServices; + +class VoteHooks { + public static $options = ['yes', 'no', 'abstain']; + + public static function onParserFirstCallInit( Parser $parser ) { + $parser->setHook( 'vote', [ self::class, 'renderVoteTag' ] ); + } + + // Render <vote> + 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(); + if ($now > $endDate) { + $voteClosed = 'The voting period has ended.'; + } else { + $tz = date_default_timezone_get(); + $text .= $msg('vote-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')); + } + + $votes = []; + + $voteText = ''; + foreach (explode("\n", $input) as $line) { + if (str_starts_with($line, '* ')) { + $line = substr($line, 2); + [$date, $userTitle, $vote, $comment] = self::parseVote($line); + $voteText .= '* '; + + if (!$date) { + $addErrorCategory(); + $voteText .= formatError("invalid date" . ':') . " $line\n"; + continue; + } + if (!in_array($vote, self::$options)) { + $addErrorCategory(); + $voteText .= formatError("invalid vote (expected YES, NO or ABSTAIN)" . ':') . " $line\n"; + continue; + } + + $user = $userTitle->getText(); + $userLink = $parser->getLinkRenderer()->makeLink($userTitle, $user); + + 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 ($comment) { + $voteText .= $parser->recursiveTagParse($comment); + } + $voteText .= " --$userLink ". $date->format('H:i, j F Y') . "\n"; + continue; + } + + $voteText .= $line . "\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 .= '<div>'; + 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 .= '</div>'; + + $text .= $voteText; + + if ($voteClosed) { + $text .= $voteClosed; + } else { + $text .= self::votingFormHtml($parser, $votes); + $parser->addTrackingCategory('pages-with-open-votes-category', $parser->getTitle()); + } + + 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, $user, $vote, $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('<h3>' . wfMessage('vote-add-your-vote')->inLanguage($parser->getTargetLanguage()) . '</h3>') + ->addFooterHtml('<script type="application/json" data-votes>'. json_encode($votes) . '</script>'); + + // 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 SpecialPage { + function __construct() { + parent::__construct( 'InsertVote' ); + } + + function isListed() { + // prevent this page from being listed on Special:SpecialPages + // since it's not intended to be accessed directly + return false; + } + + 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(',^</vote *>?$,m', $content, $matches, PREG_OFFSET_CAPTURE); + if (!$matches) { + $output->prepareErrorPage("could not find </vote> 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 "<span class=error>$error</span>"; +} diff --git a/extension.json b/extension.json new file mode 100644 index 0000000..d681f1b --- /dev/null +++ b/extension.json @@ -0,0 +1,43 @@ +{ + "name": "Vote", + "type": "parserhook", + "license-name": "MIT", + "author": "[https://push-f.com/ Martin Fischer]", + "url": "https://git.push-f.com/mw-vote", + "version": "0.1.0", + "description": "Provides the <code><vote></code> tag.", + "MessagesDirs": { + "VoteExtension": [ + "i18n" + ] + }, + "AutoloadClasses": { + "VoteHooks": "Vote.php", + "SpecialInsertVote": "Vote.php" + }, + "SpecialPages": { + "InsertVote": "SpecialInsertVote" + }, + "Hooks": { + "ParserFirstCallInit": "VoteHooks::onParserFirstCallInit" + }, + "ResourceModules": { + "ext.vote-js": { + "localBasePath": "resources", + "remoteExtPath": "Vote/resources", + "packageFiles": ["optional.js"], + "messages": [ + "vote-edit-your-vote", + "vote-already-yes", + "vote-already-no", + "vote-already-abstain", + "vote-change" + ] + } + }, + "TrackingCategories": [ + "pages-with-open-votes-category", + "pages-with-vote-errors-category" + ], + "manifest_version": 1 +} diff --git a/i18n/en.json b/i18n/en.json new file mode 100644 index 0000000..a432e42 --- /dev/null +++ b/i18n/en.json @@ -0,0 +1,28 @@ +{ + "vote-add-your-vote": "Add your vote", + "vote-edit-your-vote": "Edit your vote", + "vote-option-yes": "I '''approve''' this proposal.", + "vote-option-no": "I '''oppose''' this proposal.", + "vote-option-abstain": "I '''abstain''' from voting on this proposal but have comments.", + "vote-editmsg-yes": "I approve this proposal.", + "vote-editmsg-no": "I oppose this proposal.", + "vote-editmsg-abstain": "I abstain from voting on this proposal.", + "vote-changed": "I changed my mind", + "vote-repeated": "repeated", + "vote-tally": "$1 {{PLURAL:$1|vote|votes}} for, $2 against and $3 {{PLURAL:$3|abstention|abstentions}}", + "vote-call-to-vote": "Nobody has voted yet ... be the first one to vote!", + "vote-comment": "Comment", + "vote-submit": "Submit", + "vote-error-unknown-attribute": "Unknown attribute '$1' for <vote> tag", + "vote-error-missing-newline": "Please add a line break before </vote>", + "vote-error-one-vote-per-page": "There can only be one active vote per page (and it has to be the first one).", + "vote-already-yes": "You already voted to approve.", + "vote-already-no": "You already voted to oppose.", + "vote-already-abstain": "You already chose to abstain.", + "vote-change": "Change your vote from $1 to $2.", + "vote-end": "This vote will end on $1.", + "vote-error-end": "Failed to parse end date (expected YYYY-MM-DD).", + "vote-cancelled": "The vote has been cancelled.", + "pages-with-open-votes-category": "Pages with open votes", + "pages-with-vote-errors-category": "Pages with vote errors" +} diff --git a/resources/optional.js b/resources/optional.js new file mode 100644 index 0000000..c5e85e9 --- /dev/null +++ b/resources/optional.js @@ -0,0 +1,51 @@ +// This JavaScript just serves to improve the user experience +// it is not at all required for submitting a vote. + +const form = document.getElementById('vote-form'); +const commentInput = form.querySelector('textarea'); +const radios = {}; + +// Make the comment input required if you select to oppose or abstain. +for (const radio of form.querySelectorAll('input[type=radio]')) { + radio.addEventListener('change', e => { + commentInput.required = e.target.value != 'yes'; + }); + radios[radio.value] = radio; +} + +// Disable the Enter key in the comment input (because +// the PHP code replaces newlines with spaces anyway). +commentInput.addEventListener('keypress', e => { + if (e.key == 'Enter') { + e.preventDefault(); + } +}); + +// Remind users what they voted for and prevent them from casting the same vote again. +const script = form.querySelector('script[data-votes]'); +const votes = JSON.parse(script.textContent); +const previousVote = votes[mw.config.get('wgUserName')]; + +if (previousVote) { + const note = document.createElement('span'); + note.textContent = mw.message('vote-already-' + previousVote).text(); + script.insertAdjacentElement('beforebegin', note); + radios[previousVote].checked = true; + const submitButton = form.querySelector('input[type=submit]'); + submitButton.disabled = true; + form.querySelector('h3').textContent = mw.message('vote-edit-your-vote').text(); + + for (const radio of Object.values(radios)) { + radio.addEventListener('change', e => { + if (e.target.value == previousVote) { + submitButton.disabled = true; + note.textContent = mw.message('vote-already-' + previousVote).text(); + } else { + submitButton.disabled = false; + note.textContent = mw.message('vote-change', previousVote, e.target.value).text(); + } + }) + } +} + +// FUTURE: disable form if you are logged in but may not edit the page |