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> • </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')));
});