Jump to content

User:Max/EasyVoteMeta.js

From Meta, a Wikimedia project coordination wiki

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
// Works on Steward_requests/Global_permissions and Meta:Requests for adminship
// <nowiki>
$(() => {
	'use strict';

	const EasyVoteMeta = {
		config: {
			messages: {
				windowTitle: 'EasyVoteMeta',
				windowButton: 'Submit',
				vote: 'Please select your !vote:',
				pleaseSelectVote: 'Please select your !vote',
				confirmPossibleDouble: 'It appears that you have previously commented on this request. Are you sure you want to continue? Please strike out past comments!',
				editError: 'Error: Reload the page (F5) and try again',
				comment: 'Comment (optional):',
				signatureAutoAdd: 'Your signature will be added automatically.'
			},
			voteOptions: {
				support: {
					label: 'Support',
					template: 'support'
				},
				strongsupport: {
					label: 'Strong support',
					template: 'strong support'
				},
				weaksupport: {
					label: 'Weak support',
					template: 'weak support'
				},
				oppose: {
					label: 'Oppose',
					template: 'oppose'
				},
				strongoppose: {
					label: 'Strong oppose',
					template: 'strong oppose'
				},
				weakoppose: {
					label: 'Weak oppose',
					template: 'weak oppose'
				},
				neutral: {
					label: 'Neutral',
					template: 'neutral'
				},
				moralsupport: {
					label: 'Moral support',
					template: 'moral support'
				},
				abstain: {
					label: 'Abstain',
					template: 'abstain'
				}
			},
			iconMap: {
				support: 'https://upload.wikimedia.org/wikipedia/commons/9/94/Symbol_support_vote.svg',
				strongsupport: 'https://upload.wikimedia.org/wikipedia/commons/8/84/Symbol_strong_support_vote.svg',
				weaksupport: 'https://upload.wikimedia.org/wikipedia/commons/8/8c/GA_candidate.svg',
				moralsupport: 'https://upload.wikimedia.org/wikipedia/commons/9/94/Symbol_support_vote.svg',
				neutral: 'https://upload.wikimedia.org/wikipedia/commons/8/89/Symbol_neutral_vote.svg',
				oppose: 'https://upload.wikimedia.org/wikipedia/commons/7/7f/Symbol_oppose_vote.svg',
				weakoppose: 'https://upload.wikimedia.org/wikipedia/commons/f/f5/BA_candidate.svg',
				strongoppose: 'https://upload.wikimedia.org/wikipedia/commons/5/5e/Symbol_oppose_vote_oversat.svg',
				abstain: 'https://upload.wikimedia.org/wikipedia/commons/6/61/Symbol_abstain_vote.svg'
			},
			availableLangs: ['en'],
			editSummary: '',
			summfix: ' ([[User:Max/EasyVoteMeta.js|EVM]])',
			pageConfigs: {
				'Steward requests/Global permissions': {
					type: 'steward',
					targetPage: 'Steward requests/Global permissions'
				},
				'Meta:Requests for adminship': {
					type: 'meta',
					targetPage: 'Meta:Requests for adminship'
				}
			}
		},

		currentPageType: null,
		currentPageConfig: null,

		init() {
			const currentPage = mw.config.get('wgPageName').replace(/_/g, ' ');
			this.currentPageConfig = this.config.pageConfigs[currentPage];
			Object.values(this.config.iconMap).forEach(function(url) {
				var img = new window.Image();
				img.src = url;
			});
			if (!this.currentPageConfig) return;
			this.currentPageType = this.currentPageConfig.type;
			if (mw.user.isAnon()) return;
			mw.loader.using('mediawiki.api', this.run.bind(this));
		},

		run() {
			this.addStyle();
			const headingSelector = 'div.mw-heading.mw-heading3';

			$(headingSelector).each((_, heading) => {
				const $heading = $(heading);
				const editLinks = $heading.find('.mw-editsection');
				const sectionTitle = $heading.find('h3').attr('id').replace(/_/g, ' ');
				const normalizedTitle = sectionTitle.toLowerCase();

				if (
					normalizedTitle.startsWith("epic") ||
					normalizedTitle.startsWith("infinite0694") ||
					normalizedTitle.startsWith("global ip block exempt") ||
					normalizedTitle.startsWith("captcha exemptions") ||
					normalizedTitle.startsWith("add global vrt") ||
					normalizedTitle.startsWith("remove global vrt") ||
					normalizedTitle.startsWith("2fa tester")
				) return;

				if (this.currentPageType === 'steward') {
					this.handleStewardPage(editLinks, sectionTitle, $heading);
				} else if (this.currentPageType === 'meta') {
					this.handleMetaPage(editLinks, sectionTitle, $heading);
				}
			});
		},

		handleStewardPage(editLinks, sectionTitle, $heading) {
			const sectionNum = this.getSectionNumber(editLinks);
			if (sectionNum !== -1) {
				this.addVoteLink(editLinks, sectionTitle, { type: 'steward', sectionNum: sectionNum }, $heading);
			}
		},

		handleMetaPage(editLinks, sectionTitle, $heading) {
			const normalizedTitle = sectionTitle.toLowerCase();
			if (
				normalizedTitle.startsWith("regular adminship") ||
				normalizedTitle.startsWith("bureaucratship") ||
				normalizedTitle.startsWith("other access") ||
				normalizedTitle.startsWith("wmf office staff and contractors")
			) return;
			const targetPageInfo = this.getTargetPageInfo(editLinks, $heading);
			if (targetPageInfo && targetPageInfo.targetPage) {
				this.addVoteLink(editLinks, sectionTitle, { type: 'meta', ...targetPageInfo }, $heading);
			}
		},

		addStyle() {
			mw.util.addCSS(`
				.easyvote-meta-link {
					color: #0645ad;
					text-decoration: none;
					font-weight: bold;
				}
				.easyvote-meta-link:hover {
					text-decoration: underline;
				}
				.easyvote-meta-disabled {
					color: #888 !important;
					pointer-events: none;
				}
				.easyvote-meta-dialog {
					position: fixed;
					top: 50%;
					left: 50%;
					transform: translate(-50%, -50%);
					background: white;
					border: 1px solid #a2a9b1;
					border-radius: 8px;
					box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
					width: 520px;
					max-height: 85vh;
					z-index: 1001;
					font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Lato, Helvetica, Arial, sans-serif;
				}
				.easyvote-meta-dialog-header {
					padding: 16px 20px;
					border-bottom: 1px solid #a2a9b1;
					background: #eaecf0;
					font-weight: bold;
					font-size: 16px;
					border-radius: 8px 8px 0 0;
				}
				.easyvote-meta-dialog-body {
					padding: 20px;
					max-height: calc(85vh - 125px);
					overflow-y: auto;
				}
				.easyvote-meta-dialog-body p {
				margin-top: 0;
				}
				.easyvote-meta-dialog-footer {
					padding: 16px 20px;
					border-top: 1px solid #a2a9b1;
					background: #f8f9fa;
					text-align: right;
					border-radius: 0 0 8px 8px;
				}
				.easyvote-meta-overlay {
					position: fixed;
					top: 0;
					left: 0;
					width: 100%;
					height: 100%;
					background: rgba(0, 0, 0, 0.2);
					z-index: 1000;
				}
				.easyvote-meta-option {
					margin: 8px 0;
					display: flex;
					align-items: center;
				}
				.easyvote-meta-option input {
					margin-right: 8px;
				}
				.easyvote-meta-option label {
					margin: 0;
					cursor: pointer !important;
					display: flex;
					align-items: center;
				}
				.easyvote-meta-option img {
					margin-right: 6px;
					vertical-align: middle;
				}
				.easyvote-meta-button {
					background: #36c;
					color: white;
					border: 1px solid #36c;
					border-radius: 6px;
					padding: 6px 12px;
					font-size: 14px;
					cursor: pointer !important;
					margin-left: 8px;
					position: relative;
					min-width: 80px;
				}
				.easyvote-meta-button:hover:not(:disabled) {
					background: #447ff5;
					border-color: #447ff5;
				}
				.easyvote-meta-button:disabled {
					background: #c8ccd1;
					border-color: #c8ccd1;
					cursor: not-allowed;
				}
				.easyvote-meta-button-secondary {
					background: #f8f9fa;
					color: #202122;
					border-radius: 6px;
					border: 1px solid #a2a9b1;
				}
				.easyvote-meta-button-secondary:hover {
					background: #eaecf0 !important;
					border-color: #a2a9b1;
					color: #202122;
				}
				.easyvote-meta-textarea {
					width: 100%;
					height: 60px;
					resize: vertical;
					font-family: monospace;
					border: 1px solid #a2a9b1;
					border-radius: 2px;
					padding: 8px;
					margin-top: 4px;
				}
				.easyvote-meta-signature-note {
					font-style: italic;
					color: #666;
					font-size: 0.9em;
					margin-top: 8px;
				}
				.easyvote-meta-spinner {
					display: inline-block;
					width: 12px;
					height: 12px;
					border: 1.5px solid #f3f3f3;
					border-top: 1.5px solid #36c;
					border-radius: 50%;
					animation: easyvote-meta-spin 1s linear infinite;
					position: absolute;
					right: 10px;
					top: 50%;
					transform: translateY(-50%);
				}
				@keyframes easyvote-meta-spin {
					0% { transform: translateY(-50%) rotate(0deg); }
					100% { transform: translateY(-50%) rotate(360deg); }
				}
				.easyvote-meta-button.loading {
					padding-right: 35px;
				}
			`);
		},

		getSectionNumber(editLinks) {
			const editSectionUrl = editLinks.find('a:first').attr('href');
			const sectionMatch = editSectionUrl && editSectionUrl.match(/&section=(\d+)(?:$|&)/);
			return sectionMatch ? parseInt(sectionMatch[1]) : -1;
		},

		getTargetPageInfo(editLinks, $heading) {
			const editSectionUrl = editLinks.find('a:first').attr('href');
			if (editSectionUrl) {
				const titleMatch = editSectionUrl.match(/[?&]title=([^&]+)/);
				const sectionMatch = editSectionUrl.match(/[?&]section=([^&]+)/);
				
				if (titleMatch) {
					const targetPage = decodeURIComponent(titleMatch[1]).replace(/_/g, ' ');
					const sectionId = sectionMatch ? sectionMatch[1] : null;
					return {
						targetPage: targetPage,
						sectionId: sectionId
					};
				}
			}
			
			const headingLink = $heading.find('h3 a').first();
			if (headingLink.length) {
				const title = headingLink.attr('title');
				if (title) {
					return {
						targetPage: title,
						sectionId: 'T-1'
					};
				}
			}
			
			return null;
		},

		addVoteLink(editLinks, sectionTitle, pageInfo, $heading) {
			const uniqueId = 'easyvote-meta-launch-' + Math.random().toString(36).substr(2, 9);
			
			const voteLink = $('<a>')
				.attr('id', uniqueId)
				.addClass('easyvote-meta-link')
				.text('!vote')
				.attr('href', '#')
				.click((event) => {
					event.preventDefault();
					if (!voteLink.hasClass('easyvote-meta-disabled')) {
						voteLink.addClass('easyvote-meta-disabled');
						this.openDialog(sectionTitle, pageInfo, $heading, uniqueId);
					}
				});

			editLinks.children().last().before(' | ', voteLink);
		},

		openDialog(sectionTitle, pageInfo, $heading, uniqueId) {
			const self = this;

			if (pageInfo.type === 'meta') {
				console.log('EasyVoteMeta: Target page that will be edited:', pageInfo.targetPage);
			}

			$('.easyvote-meta-dialog, .easyvote-meta-overlay').remove();

			const $overlay = $('<div>').addClass('easyvote-meta-overlay').click((e) => {
				if (e.target === e.currentTarget) {
					this.closeDialog(uniqueId);
				}
			});

			const $dialog = $('<div>')
				.addClass('easyvote-meta-dialog')
				.data('page-info', pageInfo)
				.data('section-heading', $heading)
				.data('unique-id', uniqueId);

			const $header = $('<div>')
				.addClass('easyvote-meta-dialog-header')
				.text(this.config.messages.windowTitle + ': ' + sectionTitle);

			const $body = $('<div>')
				.addClass('easyvote-meta-dialog-body');

			const $form = $('<form>')
				.attr('name', 'easyVoteMetaForm')
				.append($('<p>').text(this.config.messages.vote));

			Object.keys(this.config.voteOptions).forEach(key => {
				$form.append(this.createVoteOption(key));
			});

			$form.append($('<br>'))
				.append($('<label>')
					.attr('for', 'easyvote-meta-comment')
					.text(this.config.messages.comment))
				.append($('<br>'))
				.append($('<textarea>')
					.attr({
						id: 'easyvote-meta-comment',
						name: 'comment',
						rows: 3,
						cols: 50
					})
					.addClass('easyvote-meta-textarea'))
				.append($('<div>')
					.addClass('easyvote-meta-signature-note')
					.text(this.config.messages.signatureAutoAdd));

			$body.append($form);

			const $footer = $('<div>')
				.addClass('easyvote-meta-dialog-footer');

			const $cancelButton = $('<button>')
				.addClass('easyvote-meta-button easyvote-meta-button-secondary')
				.text('Cancel')
				.click((e) => {
					e.preventDefault();
					this.closeDialog(uniqueId);
				});

			const $submitButton = $('<button>')
				.addClass('easyvote-meta-button')
				.attr('id', 'easyvote-meta-button')
				.attr('disabled', true)
				.text(this.config.messages.windowButton)
				.click((e) => {
					e.preventDefault();
					this.doVote($dialog);
				});

			$footer.append($cancelButton, $submitButton);

			$dialog.append($header, $body, $footer);

			$('body').append($overlay, $dialog);

			$form.find('input[name="vote"]').on('change', function() {
				$submitButton.prop('disabled', false);
			});

			setTimeout(() => {
				$form.find('input[name="vote"]:first').focus();
			}, 100);
		},

		closeDialog(uniqueId) {
			$('.easyvote-meta-dialog, .easyvote-meta-overlay').remove();
			$(`#${uniqueId}`).removeClass('easyvote-meta-disabled');
		},

		createVoteOption(type) {
			const voteOption = this.config.voteOptions[type];
			const iconUrl = this.config.iconMap[type];

			return $('<div>')
				.addClass('easyvote-meta-option')
				.append(
					$('<input>')
						.attr({
							type: 'radio',
							name: 'vote',
							value: type,
							id: 'easyvote-meta-' + type
						})
				)
				.append(
					$('<label>')
						.attr('for', 'easyvote-meta-' + type)
						.append(
							$('<img>')
								.attr({
									src: iconUrl,
									alt: '',
									width: 20,
									height: 20
								})
						)
						.append(voteOption.label)
				);
		},

		doVote($dialog) {
			const self = this;

            function onFail(error) {
                const $voteButton = $('#easyvote-meta-button');
                $voteButton.removeClass('loading').prop('disabled', false).text(self.config.messages.windowButton);
                $voteButton.find('.easyvote-meta-spinner').remove();
                console.error('Submission failed:', error);
                alert(self.config.messages.editError);
            }

			let vote, comment, pageInfo, $heading, sectionTitle, uniqueId;
			const $voteButton = $('#easyvote-meta-button');

			const $checkedVote = $dialog.find('input[name="vote"]:checked');
			if (!$checkedVote.length) {
				alert(this.config.messages.pleaseSelectVote);
				return false;
			}

			vote = $checkedVote.val();
			comment = $dialog.find('#easyvote-meta-comment').val().trim();
			pageInfo = $dialog.data('page-info');
			$heading = $dialog.data('section-heading');
			uniqueId = $dialog.data('unique-id');
			sectionTitle = $heading.contents().not($heading.find('.mw-editsection')).text().trim();

            $voteButton.addClass('loading').prop('disabled', true);
            const $spinner = $('<span>').addClass('easyvote-meta-spinner');
            $voteButton.append($spinner);

			const templateName = this.config.voteOptions[vote].template;
			let voteLine = '\n* {{' + templateName + '}}';
			if (comment) voteLine += ' ' + comment;
			voteLine += ' ~~~~';

			const voteDisplayName = this.config.voteOptions[vote]?.label || vote;
			let editSummary = `/* ${sectionTitle} */ ${voteDisplayName}${this.config.summfix}`;

			const api = new mw.Api();

			if (pageInfo.type === 'steward') {
				this.doStewardVote(api, pageInfo, voteLine, editSummary, $dialog, onFail);
			} else if (pageInfo.type === 'meta') {
				this.doMetaVote(api, pageInfo, voteLine, editSummary, $dialog, onFail);
			}
		},

		doStewardVote(api, pageInfo, voteLine, editSummary, $dialog, onFail) {
			api.get({
				action: 'query',
				prop: 'revisions',
				titles: mw.config.get('wgPageName'),
				rvprop: 'content',
				rvsection: pageInfo.sectionNum,
				formatversion: 2
			}).then((res) => {
				const page = res.query.pages[0];
				if (!page || !page.revisions || !page.revisions[0]) {
					throw new Error('Failed to fetch section content');
				}

				const sectionText = page.revisions[0].content;
				const currentUser = mw.config.get('wgUserName');

				const userVotePattern = new RegExp('\\{\\{(support|oppose|strong support|weak support|strong oppose|weak oppose|neutral|moral support|s|ss|o|)\\}\\}[^~]*' + mw.util.escapeRegExp(currentUser), 'i');

				if (userVotePattern.test(sectionText)) {
                    if (!confirm(this.config.messages.confirmPossibleDouble)) {
                        const $voteButton = $('#easyvote-meta-button');
                        $voteButton.removeClass('loading').prop('disabled', false).text(this.config.messages.windowButton);
                        $voteButton.find('.easyvote-meta-spinner').remove();
                        return;
                    }
				}

				return api.postWithEditToken({
					action: 'edit',
					title: mw.config.get('wgPageName'),
					section: pageInfo.sectionNum,
					appendtext: voteLine,
					summary: editSummary,
					token: mw.user.tokens.get('csrfToken')
				});
			}).then(() => {
				setTimeout(() => {
					window.location.reload();
				}, 1500);
			}).catch(onFail);
		},

		doMetaVote(api, pageInfo, voteLine, editSummary, $dialog, onFail) {
			api.get({
				action: 'query',
				prop: 'revisions',
				titles: pageInfo.targetPage,
				rvprop: 'content',
				rvsection: pageInfo.sectionId,
				formatversion: 2
			}).then((res) => {
				const page = res.query.pages[0];
				if (!page || !page.revisions || !page.revisions[0]) {
					throw new Error('failed to fetch section content from target page');
				}

				const sectionText = page.revisions[0].content;
				const currentUser = mw.config.get('wgUserName');

				const userVotePattern = new RegExp('\\{\\{(support|oppose|strong support|weak support|strong oppose|weak oppose|neutral|moral support|s|ss|o|)\\}\\}[^~]*' + mw.util.escapeRegExp(currentUser), 'i');

				if (userVotePattern.test(sectionText)) {
                    if (!confirm(this.config.messages.confirmPossibleDouble)) {
                        const $voteButton = $('#easyvote-meta-button');
                        $voteButton.removeClass('loading').prop('disabled', false).text(this.config.messages.windowButton);
                        $voteButton.find('.easyvote-meta-spinner').remove();
                        return;
                    }
				}

				return api.postWithEditToken({
					action: 'edit',
					title: pageInfo.targetPage,
					section: pageInfo.sectionId,
					appendtext: voteLine,
					summary: editSummary,
					token: mw.user.tokens.get('csrfToken')
				});
			}).then(() => {
				setTimeout(() => {
					window.location.reload();
				}, 1500);
			}).catch(onFail);
		}
	};

	EasyVoteMeta.init();
});
// </nowiki>