MediaWiki:Gadget-RotateLink.js

mw.loader
	.using([
		'vue',
		'@wikimedia/codex',
		'mediawiki.api',
		'mediawiki.util',
		'mediawiki.Title',
	])
	.then(async (require) => {
		const title = new mw.Title(mw.config.get('wgPageName'));
		const fileExt =
			title && title.getExtension() ? title.getExtension().toLowerCase() : null;

		const ignoredExtensions = [
			'ogg',
			'oga',
			'ogv',
			'opus',
			'webm',
			'mid',
			'flac',
			'svg',
			'pdf',
			'djvu',
			'stl',
		];

		const editRestrictions = mw.config.get('wgRestrictionEdit') || [];
		const userGroups = mw.config.get('wgUserGroups') || [];

		if (
			!title ||
			mw.config.get('wgNamespaceNumber') !== 6 ||
			ignoredExtensions.includes(fileExt) ||
			(Array.isArray(window.rotateFileTypes) &&
				!window.rotateFileTypes.includes(fileExt)) ||
			(editRestrictions.length && !userGroups.includes(editRestrictions[0]))
		) {
			return;
		}

		const i18n = {
			en: {
				dialogTitle: 'Request rotation',
				dialogTitleEditing: 'Submitting...',
				dialogTitleSuccess: 'Success!',
				dialogTitleError: 'Error',
				closeDialog: 'Close',
				submitBtn: 'Confirm',
				cancelBtn: 'Cancel',
				angleLabel:
					'By how many degrees clockwise should this image be rotated?',
				angleDescription:
					'You can use this function to correct images which display in the wrong orientation (as frequently occurs with vertical orientation digital photos).',
				noteAngle:
					'If you request a rotation by 90, 180 or 270°, Rotatebot will do this in a few hours. If you request a rotation by any other angle it will probably take longer.',
				editingMessage:
					'Rotatebot can execute this request in a few hours. Please wait...',
				successMessage:
					'Rotation request successfully added. Reloading page...',
				unknownError: 'An unknown error occurred',
				portletTitle: 'Request image rotation',
				portletLabel: 'request rotation',
				helpLinkText: 'Help',
				bugReportLinkText: 'Report bugs',
			},
		};
		const lang = mw.config.get('wgUserLanguage');
		const msg = (key) =>
			(i18n[lang] && i18n[lang][key]) ||
			(i18n[lang.split('-')[0]] && i18n[lang.split('-')[0]][key]) ||
			i18n.en[key];

		const { createMwApp } = require('vue'),
			{
				CdxButton,
				CdxDialog,
				CdxField,
				CdxTextInput,
				CdxMessage,
			} = require('@wikimedia/codex'),
			api = new mw.Api({ userAgent: 'Gadget-Rotate/commonswiki' }),
			app = createMwApp({
				data: () => ({
					dialogShown: false,
					status: 'form',
					angle: 0,
					errorMsg: null,
					previewUrl: '',
					waitTime: '',
				}),
				methods: {
					msg,
					getUrl: (page, params) => mw.util.getUrl(page, params),
					async onSubmit() {
						this.status = 'editing';
						this.errorMsg = null;

						try {
							const pageName = mw.config.get('wgPageName');
							const safeAngle = ((this.angle % 360) + 360) % 360;
							const newTemplate = '{{rotate|' + safeAngle + '}}\n';
							const templateRegex = /\{\{rotate\|.+?\}\}\n?/gi;

							// Fetch current page wikitext to check for existing template
							const rawUrl = mw.util.getUrl(pageName, {
								action: 'raw',
								_: Date.now(),
							});
							const response = await fetch(rawUrl);
							if (!response.ok) {
								throw { code: 'fetchfail', data: null };
							}
							const rawText = await response.text();

							const editParams = {
								action: 'edit',
								title: pageName,
								nocreate: 1,
								tags: 'RotateLink',
								summary: `Requesting rotation of the image by ${safeAngle}°`,
								format: 'json',
							};

							const cleanedText = rawText.replace(templateRegex, '');
							if (cleanedText !== rawText) {
								// Existing template found — replace it
								editParams.text = newTemplate + cleanedText;
							} else {
								// No existing template — just prepend
								editParams.prependtext = newTemplate;
							}

							await api
								.postWithEditToken(editParams)
								.catch(function (code, data) {
									throw { code: code, data: data };
								});

							this.status = 'success';
							setTimeout(() => {
								window.location.reload();
							}, 2000);
						} catch (err) {
							let code = err;
							let data = null;
							if (typeof err === 'object') {
								code = err.code || err;
								data = err.data || null;
							}
							this.errorMsg =
								(data && data.error && data.error.info) ||
								code ||
								msg('unknownError');
							this.status = 'error';
						}
					},
					handleKeyDown(e) {
						if (!this.dialogShown || this.status !== 'form') return;
						const step = e.shiftKey ? 10 : 1;
						if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
							this.angle = (this.angle + step) % 360;
							e.preventDefault();
						} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
							this.angle = (this.angle - step + 360) % 360;
							e.preventDefault();
						}
					},
					toggleDialog() {
						this.dialogShown = !this.dialogShown;
						if (this.dialogShown) {
							this.status = 'form';
							this.errorMsg = null;
							this.waitTime = '';

							const fileImg = document.querySelector('#file img');
							this.previewUrl = fileImg ? fileImg.src : '';

							api
								.get({
									action: 'parse',
									page: 'User:Rotatebot/approx max wait time rotatelink',
									prop: 'text',
									disablelimitreport: true,
									formatversion: 2,
								})
								.done((data) => {
									if (data.parse && data.parse.text) {
										const parser = new DOMParser();
										const doc = parser.parseFromString(
											data.parse.text,
											'text/html',
										);
										this.waitTime = doc.body.textContent.trim();
									}
								})
								.catch(function () {
									// Silently fail — bot status is non-critical
								});

							window.addEventListener('keydown', this.handleKeyDown);
						} else {
							window.removeEventListener('keydown', this.handleKeyDown);
						}
					},
				},
				template: `
				<cdx-dialog
					v-model:open="dialogShown"
					:title="status === 'form' ? msg('dialogTitle') : status === 'editing' ? msg('dialogTitleEditing') : status === 'success' ? msg('dialogTitleSuccess') : msg('dialogTitleError')"
					:subtitle="status === 'form' ? msg('angleDescription') : undefined"
					:use-close-button="true"
					:primary-action="status === 'form' || status === 'error' ? { label: msg('submitBtn', 'confirm rotate request'), actionType: 'progressive', disabled: angle === 0 || angle === 360 } : undefined"
					:default-action="status === 'form' || status === 'error' ? { label: msg('cancelBtn') } : { label: msg('closeDialog') }"
					@primary="onSubmit"
					@default="toggleDialog"
				>
					<template #default v-if="status === 'form' || status === 'error'">
						<cdx-message v-if="status === 'error'" type="error" style="margin-bottom: 16px;">
							{{ errorMsg }}
						</cdx-message>

						<div style="display: flex; flex-direction: column; align-items: center; width: 560px; max-width: 100%;">
							<cdx-message type="warning" allow-user-dismiss style="margin-bottom: 24px; width: 560px; max-width: 100%; box-sizing: border-box;">
								This gadget was recently <a href="https://en.wikipedia.org/wiki/Rewrite_(programming)" target="_blank">rewritten</a>.<br>
								You can provide feedback and suggestions at the 
								<a :href="getUrl('MediaWiki talk:Gadget-RotateLink.js')" target="_blank">talk page</a>!
							</cdx-message>

							<div v-if="previewUrl" style="width: 100%; display: flex; justify-content: center; align-items: center; background: var(--background-color-neutral-subtle, #f8f9fa); border: 1px solid var(--border-color-subtle, #c8ccd1); border-radius: 2px; padding: 12px; height: 260px; box-sizing: border-box; overflow: hidden; position: relative; margin-bottom: 24px;">
								<img :src="previewUrl" :style="{ transform: 'rotate(' + angle + 'deg)', transition: 'transform 0.3s ease', maxWidth: '220px', maxHeight: '220px', objectFit: 'contain' }" />
								<!-- Calibration Grid -->
								<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; background-image: linear-gradient(to right, var(--border-color-subtle, #c8ccd1) 1px, transparent 1px), linear-gradient(to bottom, var(--border-color-subtle, #c8ccd1) 1px, transparent 1px); background-size: 16.66% 16.66%; border-right: 1px solid var(--border-color-subtle, #c8ccd1); border-bottom: 1px solid var(--border-color-subtle, #c8ccd1); opacity: 0.4;"></div>
							</div>

							<div style="width: 100%;">
								<cdx-field>
									<template #label>{{ msg('angleLabel') }}</template>

									<div style="display: flex; gap: 8px; margin-bottom: 12px; justify-content: center;">
										<cdx-button size="small" weight="quiet" action="progressive" @click="angle = 90">90°</cdx-button>
										<cdx-button size="small" weight="quiet" action="progressive" @click="angle = 180">180°</cdx-button>
										<cdx-button size="small" weight="quiet" action="progressive" @click="angle = 270">270°</cdx-button>
										<cdx-button size="small" weight="quiet" @click="angle = 0">Reset</cdx-button>
									</div>

									<div style="display: flex; align-items: center; gap: 16px;">
										<input 
											type="range" 
											v-model.number="angle" 
											min="0" 
											max="359" 
											step="1" 
											style="flex: 1; cursor: pointer;"
										/>
										<cdx-text-input 
											v-model.number="angle" 
											input-type="number" 
											style="width: 64px; flex: 0 0 64px; min-width: 0 !important;" 
										/>
									</div>
								</cdx-field>

								<cdx-message v-if="![90, 180, 270].includes(angle)" type="warning" style="margin-top: 16px;">
									{{ msg('noteAngle') }}
								</cdx-message>
								<cdx-message v-else type="notice" style="margin-top: 16px;">
									{{ waitTime || msg('editingMessage') }}
								</cdx-message>
							</div>
						</div>
					</template>

					<template #footer-text v-if="status === 'form'">
						<a :href="getUrl('Help:RotateLink')" target="_blank">
							{{ msg('helpLinkText') }}
						</a>
						<span> &bull; </span>
						<a :href="getUrl('MediaWiki talk:Gadget-RotateLink.js')" target="_blank">
							{{ msg('bugReportLinkText') }}
						</a>
					</template>


					<template #default v-else-if="status === 'editing'">
						<cdx-message type="notice">
							{{ msg('editingMessage') }}
						</cdx-message>
					</template>

					<template #default v-else-if="status === 'success'">
						<cdx-message type="success">
							{{ msg('successMessage') }}
						</cdx-message>
					</template>
				</cdx-dialog>
				`,
				mounted() {
					const onClick = (event) => {
						if (event) event.preventDefault();
						this.toggleDialog();
					};

					// Inline link next to file info, similar to original script
					const fileinfo = document.querySelector('#mw-content-text .fileInfo');
					if (fileinfo) {
						fileinfo.appendChild(document.createTextNode('; '));

						const link = document.createElement('a');
						link.href = '#';
						link.style.whiteSpace = 'nowrap';
						link.style.display = 'inline-block';
						link.title = msg('portletTitle');
						link.addEventListener('click', onClick);

						link.appendChild(document.createTextNode('('));

						const img = document.createElement('img');
						img.src =
							'//upload.wikimedia.org/wikipedia/commons/7/70/Silk_arrow_rotate_clockwise.png';
						img.style.marginRight = '4px';
						link.appendChild(img);

						link.appendChild(
							document.createTextNode(msg('portletLabel') + ')'),
						);

						fileinfo.appendChild(link);
					}
				},
				components: {
					'cdx-button': CdxButton,
					'cdx-dialog': CdxDialog,
					'cdx-field': CdxField,
					'cdx-text-input': CdxTextInput,
					'cdx-message': CdxMessage,
				},
			});

		app.mount(document.body.appendChild(document.createElement('div')));
	});