User:Polygnotus/Scripts/RfCMonitor.js

// <nowiki>
/**
 * RfC Monitor for Wikipedia
 * Uses the mediawiki.revision-tags-change stream to detect when the 'RfC'
 * tag is applied to a revision, then fetches the diff to classify the change.
 * This stream fires after AbuseFilter has written the tag, avoiding the
 * timing issue present when using mediawiki.recentchange.
 */

(function() {
    'use strict';

    mw.loader.using(['mediawiki.util', 'mediawiki.api'], function() {
        mw.util.addPortletLink(
            'p-tb',
            mw.util.getUrl('Special:BlankPage/RfCMonitor'),
            'RfC Monitor',
            't-rfcmonitor',
            'Monitor RfC template additions and removals'
        );
    });

    if (mw.config.get('wgCanonicalSpecialPageName') === 'Blankpage' &&
        mw.config.get('wgPageName') === 'Special:BlankPage/RfCMonitor') {

        $(document).ready(function() {
            initMonitor();
        });
    }

    function initMonitor() {
        $('#firstHeading').text('RfC Monitor');
        document.title = 'RfC Monitor';

        const $container = $('#mw-content-text');
        $container.html(`
            <div id="rfc-container">
                <div id="rfc-controls" style="margin: 15px 0;">
                    <button id="rfc-start" style="padding: 8px 16px; font-size: 14px; cursor: pointer;">Start Monitoring</button>
                    <button id="rfc-stop" style="padding: 8px 16px; font-size: 14px; cursor: pointer; display: none;">Stop Monitoring</button>
                    <button id="rfc-test-notif" style="padding: 8px 16px; font-size: 14px; cursor: pointer; margin-left: 10px;">Test Notification</button>
                    <span id="rfc-count" style="margin-left: 15px; font-weight: bold;"></span>
                </div>
                <div id="rfc-legend" style="margin: 15px 0; padding: 10px; border: 1px solid #ccc; background: #f9f9f9;">
                    <strong>Color Legend:</strong>
                    <span style="margin-left: 10px; padding: 3px 8px; background: #90ee90; border: 1px solid #555;">RfC Added</span>
                    <span style="margin-left: 10px; padding: 3px 8px; background: #ffcccb; border: 1px solid #555;">RfC Removed</span>
                    <span style="margin-left: 10px; padding: 3px 8px; background: #ffe4b5; border: 1px solid #555;">RfC Modified</span>
                    <span style="margin-left: 10px; padding: 3px 8px; background: #e0e0e0; border: 1px solid #555;">RfC Unknown</span>
                    <span id="rfc-notif-status" style="margin-left: 20px; font-style: italic; color: #555;"></span>
                </div>
                <div id="rfc-status" style="font-style: italic; color: #555; margin-bottom: 10px;"></div>
                <div id="rfc-results" style="margin-top: 10px; font-family: monospace; font-size: 12px;"></div>
            </div>
        `);

        $('#rfc-start').on('click', startMonitoring);
        $('#rfc-stop').on('click', stopMonitoring);
        $('#rfc-test-notif').on('click', sendTestNotification);

        requestNotificationPermission();
    }

    // The tag name applied by the edit filter
    const RFC_TAG = 'RfC';

    // RfC template regex
    const RFC_REGEX = /\{\{\s*rfc(?:\|\s*[a-zA-Z0-9_-]+(?:\s*[a-zA-Z0-9_-]+)*)*\s*\}\}/i;

    // revision-tags-change fires after AbuseFilter has committed the tag,
    // so the tag is guaranteed to be present when we receive the event.
    const TAGS_STREAM_URL = 'https://stream.wikimedia.org/v2/stream/mediawiki.revision-tags-change';

    const MAX_RECONNECT_DELAY = 5 * 60 * 1000;

    let isMonitoring = false;
    let rfcEventSource = null;
    let rfcEditCount = 0;
    let rfcQueue = [];
    let rfcProcessing = false;
    let reconnectDelay = 1000;
    let reconnectTimer = null;

    const api = new mw.Api();
    const seenRevisions = new Set();

    function requestNotificationPermission() {
        if (!('Notification' in window)) {
            $('#rfc-notif-status').text('Browser notifications not supported.');
            return;
        }
        if (Notification.permission === 'granted') {
            $('#rfc-notif-status').text('Notifications enabled.');
        } else if (Notification.permission !== 'denied') {
            Notification.requestPermission().then(function(permission) {
                if (permission === 'granted') {
                    $('#rfc-notif-status').text('Notifications enabled.');
                } else {
                    $('#rfc-notif-status').text('Notifications denied.');
                }
            });
        } else {
            $('#rfc-notif-status').text('Notifications denied.');
        }
    }

    function sendTestNotification() {
        if (!('Notification' in window)) {
            alert('Browser notifications are not supported.');
            return;
        }
        if (Notification.permission !== 'granted') {
            alert('Notification permission is not granted. Check the status in the legend bar.');
            return;
        }
        const n = new Notification('RfC Monitor: Test Notification', {
            body: 'Notifications are working correctly.',
            tag: 'rfc-monitor-test'
        });
        n.onclick = function() { n.close(); };
    }

    function sendNotification(item, changeType) {
        if (!('Notification' in window) || Notification.permission !== 'granted') return;

        const title = 'RfC ' + changeType.toUpperCase() + ': ' + item.title;
        const body  = 'User: ' + item.user;

        const n = new Notification(title, { body: body, tag: String(item.newRevId) });

        n.onclick = function() {
            const pageTitleEncoded = encodeURIComponent(item.title).replace(/%20/g, '_');
            window.open(
                'https://en.wikipedia.org/w/index.php?title=' + pageTitleEncoded +
                '&diff=' + item.newRevId + '&oldid=' + item.oldRevId,
                '_blank'
            );
            n.close();
        };
    }

    function startMonitoring() {
        if (isMonitoring) return;

        isMonitoring = true;
        rfcEditCount = 0;
        rfcQueue = [];
        seenRevisions.clear();
        reconnectDelay = 1000;

        $('#rfc-start').hide();
        $('#rfc-stop').show();
        $('#rfc-status').text('Monitoring active...');
        $('#rfc-results').empty();
        updateCount();

        openStream();
    }

    function openStream() {
        if (!isMonitoring) return;

        rfcEventSource = new EventSource(TAGS_STREAM_URL);

        rfcEventSource.onopen = function() {
            console.log('RfC tags-change stream opened');
            $('#rfc-status').text('Monitoring active...');
            reconnectDelay = 1000;
        };

        rfcEventSource.onerror = function(e) {
            console.error('RfC tags-change stream error:', e);
            rfcEventSource.close();
            rfcEventSource = null;

            if (!isMonitoring) return;

            $('#rfc-status').text('Connection lost. Reconnecting in ' + (reconnectDelay / 1000) + 's...');
            reconnectTimer = setTimeout(function() {
                if (isMonitoring) openStream();
            }, reconnectDelay);

            reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
        };

        rfcEventSource.onmessage = function(event) {
            try {
                const data = JSON.parse(event.data);
                filterTagsChangeEvent(data);
            } catch (e) {
                console.error('Error parsing tags-change event:', e);
            }
        };
    }

    function stopMonitoring() {
        if (!isMonitoring) return;

        isMonitoring = false;
        $('#rfc-start').show();
        $('#rfc-stop').hide();
        $('#rfc-status').text('Monitoring stopped.');

        if (rfcEventSource) {
            rfcEventSource.close();
            rfcEventSource = null;
        }
        if (reconnectTimer) {
            clearTimeout(reconnectTimer);
            reconnectTimer = null;
        }
        rfcQueue = [];
    }

    function filterTagsChangeEvent(data) {
        // Only enwiki
        if (!data.meta || data.meta.domain !== 'en.wikipedia.org') return;

        const tagsAfter  = data.tags || [];
        const tagsBefore = (data.prior_state && data.prior_state.tags) || [];

        // Only proceed if RfC tag was just added (not already present before)
        if (!tagsAfter.includes(RFC_TAG)) return;
        if (tagsBefore.includes(RFC_TAG)) return;

        const revId = data.rev_id;
        if (!revId) return;
        if (seenRevisions.has(revId)) return;
        seenRevisions.add(revId);

        // Queue the revision ID; we will fetch full details via the API
        rfcQueue.push({ revId: revId });

        if (!rfcProcessing) {
            processRfcQueue();
        }
    }

    async function processRfcQueue() {
        rfcProcessing = true;
        while (rfcQueue.length > 0) {
            if (!isMonitoring) break;
            const entry = rfcQueue.shift();
            await checkRfcRevision(entry.revId);
            await sleep(300);
        }
        rfcProcessing = false;
    }

    async function checkRfcRevision(revId) {
        try {
            // Fetch revision metadata to get parent revision ID, editor, title, namespace
            const infoResponse = await api.get({
                action: 'query',
                prop: 'revisions',
                revids: revId,
                rvprop: 'ids|user|timestamp',
                formatversion: 2
            });

            const pages = infoResponse.query && infoResponse.query.pages;
            if (!pages || pages.length === 0) return;

            const page = pages[0];
            if (page.missing) return;

            const rev = page.revisions && page.revisions[0];
            if (!rev || !rev.parentid) return;

            const item = {
                title:     page.title,
                namespace: page.ns,
                oldRevId:  rev.parentid,
                newRevId:  rev.revid,
                user:      rev.user,
                timestamp: rev.timestamp
            };

            await checkRfcDiff(item);

        } catch (e) {
            console.error('RfC revision fetch error for rev ' + revId + ':', e);
        }
    }

    async function checkRfcDiff(item) {
        try {
            const response = await api.get({
                action: 'compare',
                fromrev: item.oldRevId,
                torev: item.newRevId,
                prop: 'diff',
                formatversion: 2
            });

            const diffHtml = response.compare && response.compare.body;
            if (!diffHtml) return;

            const $diff = $('<table>').html(diffHtml);
            const addedText   = $diff.find('.diff-addedline').text();
            const removedText = $diff.find('.diff-deletedline').text();

            const rfcInAdded   = RFC_REGEX.test(addedText);
            const rfcInRemoved = RFC_REGEX.test(removedText);

            let changeType;
            if (rfcInAdded && !rfcInRemoved) {
                changeType = 'added';
            } else if (rfcInRemoved && !rfcInAdded) {
                changeType = 'removed';
            } else if (rfcInAdded && rfcInRemoved) {
                changeType = 'modified';
            } else {
                changeType = 'unknown';
            }

            displayRfcEdit(item, changeType);
            sendNotification(item, changeType);

        } catch (e) {
            console.error('RfC diff check error for rev ' + item.newRevId + ':', e);
        }
    }

    function displayRfcEdit(item, changeType) {
        rfcEditCount++;
        updateCount();

        const bgColor = changeType === 'added'    ? '#90ee90' :
                        changeType === 'removed'  ? '#ffcccb' :
                        changeType === 'modified' ? '#ffe4b5' :
                                                    '#e0e0e0';

        const pageTitleEncoded = encodeURIComponent(item.title).replace(/%20/g, '_');
        const timestamp = new Date(item.timestamp).toISOString();

        let html = `<div style="margin-bottom: 15px; padding: 10px; border: 1px solid #aaa; background: ${bgColor};">`;
        html += `<div style="font-weight: bold;">[${timestamp}] RfC ${changeType.toUpperCase()}</div>`;
        html += `<div><a href="https://en.wikipedia.org/wiki/${pageTitleEncoded}" target="_blank">${escapeHtml(item.title)}</a></div>`;
        html += `<div><a href="https://en.wikipedia.org/w/index.php?title=${pageTitleEncoded}&diff=${item.newRevId}&oldid=${item.oldRevId}" target="_blank">Diff ${item.newRevId}</a></div>`;
        html += `<div>User: <a href="https://en.wikipedia.org/wiki/User:${encodeURIComponent(item.user)}" target="_blank">${escapeHtml(item.user)}</a></div>`;
        html += `<div>Namespace: ${item.namespace}</div>`;
        html += '</div>';

        $('#rfc-results').prepend(html);

        const results = $('#rfc-results > div');
        if (results.length > 100) {
            results.slice(100).remove();
        }
    }

    function updateCount() {
        $('#rfc-count').text(`RfC changes found: ${rfcEditCount}`);
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function escapeHtml(text) {
        const map = {
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            "'": '&#039;'
        };
        return String(text).replace(/[&<>"']/g, function(m) { return map[m]; });
    }

})();
// </nowiki>

Content Disclaimer

Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.

  1. The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
  2. There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
  3. It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
  4. Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.