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',
},
vi: {
dialogTitle: 'Yêu cầu xoay hình ảnh',
dialogTitleEditing: 'Đang gửi...',
dialogTitleSuccess: 'Thành công!',
dialogTitleError: 'Lỗi',
closeDialog: 'Đóng',
submitBtn: 'Xác nhận',
cancelBtn: 'Hủy',
angleLabel: 'Ảnh này cần được xoay bao nhiêu độ? (theo chiều kim đồng hồ)',
angleDescription: 'Bạn có thể sử dụng chức năng này để sửa các hình ảnh hiển thị sai hướng (điều thường xảy ra với ảnh kỹ thuật số chụp theo chiều dọc).',
noteAngle: 'Nếu bạn chỉ yêu cầu xoay 90, 180 hoặc 270°, Rotatebot sẽ thực hiện trong vài giờ; với bất kỳ góc độ nào khác thì có thể sẽ phải đợi lâu hơn.',
editingMessage: 'Rotatebot có thể thực hiện yêu cầu này trong vài giờ nữa. Vui lòng chờ...',
successMessage: 'Yêu cầu xoay ảnh đã được thêm thành công. Đang tải lại trang...',
unknownError: 'Xuất hiện lỗi không rõ',
portletTitle: 'Yêu cầu xoay hình ảnh',
portletLabel: 'xoay hình ảnh',
helpLinkText: 'Trợ giúp',
bugReportLinkText: 'Báo cáo lỗi'
}
};
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')));
});