summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--LICENSE21
-rw-r--r--Vote.php291
-rw-r--r--extension.json43
-rw-r--r--i18n/en.json28
-rw-r--r--resources/optional.js51
5 files changed, 434 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d0099f3
--- /dev/null
+++ b/LICENSE
@@ -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>&lt;vote&gt;</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