Jump to content

User:StefenTower/ArticleLinkDiffs.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/*** Article Link Diffs ***/

// Automatically summarizes article links added or removed when viewing a diff page.
// Also, incomplete links will be caught and shown as errors.
// Documentation at [[en:w:User:StefenTower/ArticleLinkDiffs]]
// By [[en:w:User:StefenTower]]
(function() {
    'use strict';

    if (!mw.config.get('wgDiffNewId') && !window.location.search.includes('diff=')) {
        return;
    }

    // Exit if it is a JavaScript code page diff
    if (mw.config.get('wgPageName').endsWith('.js')) return;

    mw.loader.using(['mediawiki.util'], function() {
        var namespaceIds = mw.config.get('wgNamespaceIds') || {};
        var brokenLinks = []; // Track unclosed link snippets globally within instance

        // Fortified CSS selectors targeting dark, desaturated link block baselines natively
        mw.util.addCSS(
            'div#article-link-diff-summary a.alschip { display: inline-flex; align-items: center; background: var(--background-color-neutral-subtle, #f8f9fa) !important; border: 1px solid var(--border-color-muted, #c8ccd1) !important; border-radius: 4px !important; padding: 2px 6px !important; margin: 3px 6px 3px 0 !important; font-size: 0.9em !important; white-space: nowrap !important; color: var(--color-progressive, #36c) !important; font-weight: 500 !important; text-decoration: none !important; text-decoration-line: none !important; background-image: none !important; box-shadow: 0 1px 2px rgba(0,0,0,0.04) !important; transition: background 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; }' +
            'div#article-link-diff-summary a.alschip-added:hover { background: var(--background-color-success-subtle, #d4eed8) !important; border-color: var(--border-color-success, #a3cfad) !important; box-shadow: 0 1px 0 0 var(--color-success, #137333), 0 2px 4px rgba(0,0,0,0.05) !important; color: var(--color-success, #137333) !important; text-decoration: none !important; }' +
            'div#article-link-diff-summary a.alschip-removed:hover { background: var(--background-color-error-subtle, #fadbd8) !important; border-color: var(--border-color-error, #e6b0aa) !important; box-shadow: 0 1px 0 0 var(--color-error, #c5221f), 0 2px 4px rgba(0,0,0,0.05) !important; color: var(--color-error, #c5221f) !important; text-decoration: none !important; }' +
            
            // Base Light Mode style configuration for error chips (spans)
            'div#article-link-diff-summary span.alschip-error { display: inline-flex; align-items: center; background: var(--background-color-error-subtle, #fdf2f2) !important; border: 1px solid var(--border-color-error, #f8b4b4) !important; border-radius: 3px !important; padding: 1px 5px !important; margin: 1px 3px 1px 0 !important; font-family: monospace !important; font-size: 0.85em !important; color: var(--color-error, #9c1a1a) !important; white-space: nowrap !important; }' +
            
            // --- DARK MODE COMPONENT OVERRIDES ---
            // Tones down the main card summary block row container
            '.mw-theme-client-dark div#article-link-diff-summary { background: #1a1b1c !important; border-color: #3a3d40 !important; }' +
            '.mw-theme-client-dark div#article-link-diff-summary div { border-color: #2a2b2c !important; }' +
            '.mw-theme-client-dark div#article-link-diff-summary span.alschip-error { background: #351716 !important; border-color: #54211e !important; color: #f2a6a1 !important; }'
        );

        function getLinkCounts(text) {
            // Strip raw "File:Name.jpg|" prefixes from <gallery> rows so their captions parse like normal text
            var processedText = text.replace(/^\s*(File|Image):[^|\n]+\|/gim, '');

            // 1. Pre-process text to isolate inner links from File/Image captions
            var extractedInnerLinks = [];
            var fileRegex = /\[\[(File|Image):[^[\]]*(?:\[\[[^[\]]*\]\][^[\]]*)*\]\]/gi;
            var fileMatch;
            
            // Extract links nested inside file captions and save them
            while ((fileMatch = fileRegex.exec(processedText)) !== null) {
                var fileMarkup = fileMatch[0];
                var innerLinks = fileMarkup.match(/\[\[(?!File:|Image:)[^\]]+\]\]/gi);
                if (innerLinks) {
                    extractedInnerLinks = extractedInnerLinks.concat(innerLinks);
                }
            }

            // Remove the outer File/Image blocks entirely so they don't get double-counted
            var baseTextWithoutFiles = processedText.replace(fileRegex, '');
            
            // Combine the file-free text with our single copy of the extracted caption links
            var cleanText = baseTextWithoutFiles + ' \n ' + extractedInnerLinks.join(' ');

            // Standard link extraction loop running on the deduplicated text pool
            var linkRegex = /\[\[([^\]|]{1,150})(\|[^\]]{1,150})?\]\]/g;
            var counts = {};
            var match;
            
            while ((match = linkRegex.exec(cleanText)) !== null) {
                var rawTitle = match[1].trim(); 
                
                // Remove leading colon (used for inline category/file links)
                var cleanTitle = rawTitle.startsWith(':') ? rawTitle.slice(1) : rawTitle;
                var parts = cleanTitle.split(':');
                
                if (parts.length > 1) {
                    var prefix = parts[0].toLowerCase().replace(/ /g, '_');
	
                    // If the prefix is a known namespace (Category, File, User, etc.), skip it
                    if (namespaceIds.hasOwnProperty(prefix)) {
                        continue; 
                    }
                }

                // Normalize: Capitalize first letter and underscores to spaces
                var title = cleanTitle.charAt(0).toUpperCase() + cleanTitle.slice(1).replace(/_/g, ' ');
                var display = match[2] ? match[2].substring(1).trim() : null; 
                
                var key = display ? title + "|||" + display : title;
                counts[key] = (counts[key] || 0) + 1;
            }

            // 2. Independent scanner to parse and report broken runaway links
            var idx = 0;
            while ((idx = text.indexOf('[[', idx)) !== -1) {
                // Lookahead check to skip media assets entirely from errors panel
                var preview = text.substring(idx + 2, idx + 15).trim().toLowerCase();
                if (preview.startsWith('file:') || preview.startsWith('image:')) {
                    idx += 2;
                    continue; 
                }

                var nextClose = text.indexOf(']]', idx);
                var nextOpen = text.indexOf('[[', idx + 2);

                if (nextClose === -1 || (nextOpen !== -1 && nextOpen < nextClose)) {
                    var endPos = (nextOpen !== -1) ? nextOpen : idx + 40;
                    var snippet = text.substring(idx, endPos).trim();
                    
                    if (snippet.length > 40) {
                        snippet = snippet.substring(0, 40) + '...';
                    }

                    if (!brokenLinks.includes(snippet)) {
                        brokenLinks.push(snippet);
                    }
                }
                idx += 2;
            }

            return counts;
        }

        // Ensure the diff table exists before proceeding
        var diffTable = document.querySelector('table.diff');
        if (!diffTable) return;

        var oldText = Array.from(document.querySelectorAll('.diff-deletedline')).map(td => td.textContent).join(' \n ');
        var newText = Array.from(document.querySelectorAll('.diff-addedline')).map(td => td.textContent).join(' \n ');

        var oldCounts = getLinkCounts(oldText);
        var oldErrors = [].concat(brokenLinks);
        brokenLinks = []; 
        
        var newCounts = getLinkCounts(newText);
        var freshErrors = brokenLinks.filter(function(err) { return !oldErrors.includes(err); });

        function getDifference(source, target) {
            var diff = [];
            for (var key in source) {
                var sourceCount = source[key];
                var targetCount = target[key] || 0;
                if (sourceCount > targetCount) {
                    var p = key.split('|||');
                    diff.push({ title: p[0], display: p[1] || null, count: sourceCount - targetCount }); 
                }
            }
            return diff.sort((a, b) => a.title.localeCompare(b.title));
        }

        var added = getDifference(newCounts, oldCounts);
        var removed = getDifference(oldCounts, newCounts);

        if (added.length === 0 && removed.length === 0 && freshErrors.length === 0) return;

        function renderList(list, typeClass) {
            if (list.length === 0) return '<span style="color: var(--color-subtle, #72777d); font-style: italic; font-size: 0.9em; margin-left: 4px; align-self: center;">None</span>';
            
            var chipsHTML = list.map(item => {
                var html = '<a class="alschip ' + typeClass + '" href="' + mw.util.getUrl(item.title) + '">';
                html += '<span>' + item.title + '</span>';
                
                if (item.display && item.display !== item.title) {
                    html += '<span style="color: var(--color-subtle, #72777d); font-size: 0.85em; margin-left: 3px; font-weight: normal;">(as <b>&ldquo;</b>' + item.display + '<b>&rdquo;</b>)</span>';
                }
                if (item.count > 1) { 
                    html += '<span style="background: var(--background-color-neutral, #eaecf0); color: var(--color-base, #202122); font-weight: bold; font-size: 0.8em; padding: 0 3px; border-radius: 2px; margin-left: 4px;">×' + item.count + '</span>'; 
                }
                html += '</a>';
                return html;
            }).join('');

            return '<div style="display: inline-flex; flex-wrap: wrap; align-items: center; vertical-align: middle; flex: 1;">' + chipsHTML + '</div>';
        }

        var priorReport = document.getElementById('article-link-diff-summary');
        if (priorReport) priorReport.remove();

        var report = document.createElement('div');
        report.id = 'article-link-diff-summary';
        
        var cardStyles = 'margin: 8px 0; padding: 8px 12px; background: var(--background-color-neutral-subtle, #ffffff); border: 1px solid var(--border-color-base, #a2a9b1); border-left: 4px solid #36c; border-radius: 4px; font-family: sans-serif; box-sizing: border-box; width: 100%; line-height: 1.4; color: var(--color-base, #202122);';
        report.style.cssText = cardStyles;
        
        var totalAdded = added.reduce((sum, i) => sum + i.count, 0);
        var totalRemoved = removed.reduce((sum, i) => sum + i.count, 0);

        // Lightweight, uniform link emblem using style filters to ensure a sleek monochrome look
        var reportHTML = '<div style="font-size: 0.95em; font-weight: bold; color: var(--color-base, #202122); margin-bottom: 4px; padding-bottom: 6px; border-bottom: 1px solid var(--border-color-subtle, #f0f1f2); display: flex; align-items: center; gap: 5px;">' +
            '<span style="font-size: 1.15em; display: inline-flex; align-items: center; justify-content: center; filter: grayscale(1) brightness(1.2); opacity: 0.75; transform: rotate(-15deg); line-height: 1;">🔗</span>' +
            '<span>Article Link Diff Summary</span>' +
            '</div>';
        
        reportHTML += '<div style="display: flex; margin-bottom: 3px; align-items: flex-start;">' +
            '<div style="display: inline-block; width: 95px; flex-shrink: 0; background: var(--background-color-success-subtle, #e6f4ea); color: var(--color-success, #137333); font-weight: bold; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.3px; padding: 1px 4px; border-radius: 3px; text-align: center; margin-right: 6px; margin-top: 2px; white-space: nowrap;">Added (' + totalAdded + ')</div>' + 
            renderList(added, 'alschip-added') + 
            '</div>';
            
        reportHTML += '<div style="display: flex; margin-bottom: 3px; align-items: flex-start;">' +
            '<div style="display: inline-block; width: 95px; flex-shrink: 0; background: var(--background-color-error-subtle, #fce8e6); color: var(--color-error, #c5221f); font-weight: bold; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.3px; padding: 1px 4px; border-radius: 3px; text-align: center; margin-right: 6px; margin-top: 2px; white-space: nowrap;">Removed (' + totalRemoved + ')</div>' + 
            renderList(removed, 'alschip-removed') + 
            '</div>';

        if (freshErrors.length > 0) {
            report.style.borderLeftColor = '#d33';
            
            var errorChips = freshErrors.map(function(e) { 
                return '<span class="alschip-error">' + mw.html.escape(e) + '</span>'; 
            }).join('');

            reportHTML += '<div style="display: flex; margin-top: 4px; padding-top: 4px; border-top: 1px dashed #eaecf0; align-items: flex-start;">' +
                '<div style="display: inline-block; width: 95px; flex-shrink: 0; background: var(--background-color-notice-subtle, #feefe3); color: var(--color-notice, #b06000); font-weight: bold; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.3px; padding: 1px 4px; border-radius: 3px; text-align: center; margin-right: 6px; margin-top: 2px; white-space: nowrap;">⚠️ Errors (' + freshErrors.length + ')</div>' +
                '<div style="display: inline-flex; flex-wrap: wrap; align-items: center; vertical-align: middle; flex: 1;">' + errorChips + '</div>' +
                '</div>';
        }

        report.innerHTML = reportHTML;
        diffTable.parentNode.insertBefore(report, diffTable);
		mw.hook('wikipage.content').fire($(report));
    });
})();