Jump to content

User:Enterprisey/fancy-diffs.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.
// vim: ts=4 sw=4 et ai
( function () {
    var api;
    var DC_CLS = ' class="diffchange diffchange-inline"'; // the CSS classes for the diffchange (<ins>/<del>) spans

    function processText( text, pageName ) {
        var chunks = [];

        // Types for chunks (I really should've called them "tokens")
        var TEXT = 0;
        var INS_START = 1;
        var INS_END = 2;
        var DEL_START = 3;
        var DEL_END = 4;
        var A_START = 5;
        var A_END = 6;
        var EXPAND = 7;

        // Throughout, "ins or del" is abbreviated as "change" or "chg"
        var CHG_RGX = /<(ins|del) class="diffchange diffchange-inline">([^<]+?)($|<\/\1>)/g;
        var lastChgEnd = 0;
        var chgMatch;
        var justText = "";
        var firstTextSegment;
        var isIns;
        do {
            chgMatch = CHG_RGX.exec( text );
            if( chgMatch ) {
                firstTextSegment = text.substring( lastChgEnd, chgMatch.index );
                if( firstTextSegment.length ) {
                    chunks.push( { ty: TEXT, txt: firstTextSegment, idx: justText.length } );
                    justText += firstTextSegment;
                }
                isIns = chgMatch[1] === "ins"; chunks.push( { ty: isIns ? INS_START : DEL_START } );
                chunks.push( { ty: TEXT, txt: chgMatch[2], idx: justText.length } );
                chunks.push( { ty: isIns ? INS_END : DEL_END } );
                justText += chgMatch[2];
                lastChgEnd = chgMatch.index + chgMatch[0].length;
            }
        } while( chgMatch );
        if( lastChgEnd <= text.length - 1 ) {
            chunks.push( { ty: TEXT, txt: text.substring( lastChgEnd ), idx: justText.length } );
            justText += text.substring( lastChgEnd );
        }

        var markupHandlers = [
            {
                regex: /\[\[(.+?)(?:\|.+?)?\]\]/g,
                handler: function ( match ) {
                    var linkTarget = match[1];
                    if( linkTarget.indexOf( "#" ) === 0 ) {
                        linkTarget = pageName + linkTarget;
                    }
                    var result = [
                        { ty: A_START, url: mw.util.getUrl( linkTarget ) },
                        { ty: TEXT, txt: match[0] },
                        { ty: A_END }
                    ];

                    if( linkTarget.indexOf( "File:" ) === 0 || linkTarget.indexOf( "Image:" ) === 0 ) {
                        result.push( { ty: EXPAND, expandTy: "img", data: linkTarget.replace( /"/g, "&quot;" ) } );
                    }

                    return result;
                }
            },
            {
                regex: /\{\{(.+?)(?:\|.+?)?\}\}/g,
                handler: function ( match ) {
                    var name = match[1],
                        fullName = name;
                    if( name.indexOf( "#" ) === 0 ) {
                        fullName = name.replace( /^#invoke:/, "Module:" );
                    } else if( name.indexOf( ":" ) < 0 ) {
                        fullName = "Template:" + name;
                    }

                    return [
                        { ty: TEXT, txt: "{{" }, // "}}" pour one out for vim's syntax highlighter
                        { ty: A_START, url: mw.util.getUrl( fullName ) },
                        { ty: TEXT, txt: match[1] },
                        { ty: A_END },
                        { ty: TEXT, txt: match[0].substring( 2 + name.length ) }
                    ];
                }
            },
            {
                regex: /(?:(?:https|http|gopher|irc|ircs|ftp|news|nnttp|worldwind|telnet|svn|git|mms):\/\/|mailto:)([!#$&-;=?-\[\]_a-z~]|%[0-9a-fA-F]{2})+/g,
                handler: function ( match ) {
                    var url = match[0];
                    if( match.input[ match.index - 1 ] === "[" && url[url.length - 1] === "]" ) {
                        url = url.substring( 0, url.length - 1 );
                    }
                    return [
                        { ty: A_START, url: url },
                        { ty: TEXT, txt: url },
                        { ty: A_END },
                    ];
                }
            }
        ];

        // Definitely among the trickiest code I've written for a user
        // script to date. The version that kept detailed track of
        // string indices was much worse, trust me!
        for( var handlerIdx = 0; handlerIdx < markupHandlers.length; handlerIdx++ ) {
            var regex = markupHandlers[handlerIdx].regex,
                handler = markupHandlers[handlerIdx].handler;
            var match;
            do {
                match = regex.exec( justText );
                if( match ) {
                    var replacementChunks = handler( match );

                    // Locate the start and end of `match` in `chunks`
                    var startChunkIdx = -1,
                        endChunkIdx = -1;
                    for( var chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++ ) {
                        if( chunks[chunkIdx].ty !== TEXT ) {
                            continue;
                        } else if( startChunkIdx < 0 ) {
                            if( ( chunks[chunkIdx].idx + chunks[chunkIdx].txt.length ) > match.index ) {
                                startChunkIdx = chunkIdx;
                            }
                        }
                        if( ( startChunkIdx >= 0 ) && chunks[chunkIdx].idx >= ( match.index + match[0].length ) ) {
                            endChunkIdx = chunkIdx;
                            break;
                        }
                    }

                    // Edge-case handling for the start/end chunk locator
                    if( startChunkIdx < 0 ) {
                        console.error( "whoops" );
                    } else if( endChunkIdx < 0 ) {
                        endChunkIdx = chunks.length - 1;
                    } else {
                        endChunkIdx--;
                    }
                    while( chunks[endChunkIdx].ty !== TEXT ) endChunkIdx--;

                    // Split the start and end chunks, so we can cleanly insert the A_START and A_END
                    var startChunk = chunks[startChunkIdx];
                    var idxInStartChunk = match.index - startChunk.idx;
                    if( idxInStartChunk > 0 && idxInStartChunk < ( startChunk.txt.length - 1 ) ) {
                        chunks.splice( startChunkIdx, 1,
                            { ty: TEXT, txt: startChunk.txt.substring( 0, idxInStartChunk ), idx: startChunk.idx },
                            { ty: TEXT, txt: startChunk.txt.substring( idxInStartChunk ), idx: startChunk.idx + idxInStartChunk }
                        );
                        startChunkIdx++;
                        startChunk = chunks[startChunkIdx];
                        endChunkIdx++;
                    }

                    var endChunk = chunks[endChunkIdx];
                    var idxInEndChunk = match.index + match[0].length - endChunk.idx;
                    if( idxInEndChunk > 0 && idxInEndChunk < ( endChunk.txt.length - 1 ) ) {
                        chunks.splice( endChunkIdx, 1,
                            { ty: TEXT, txt: endChunk.txt.substring( 0, idxInEndChunk ), idx: endChunk.idx },
                            { ty: TEXT, txt: endChunk.txt.substring( idxInEndChunk ), idx: endChunk.idx + idxInEndChunk }
                        );
                    }

                    // Make sure the new text chunks have correct idx's set
                    var replacementTextLength = 0;
                    for( var i = 0; i < replacementChunks.length; i++ ) {
                        if( replacementChunks[i].ty === TEXT ) {
                            replacementChunks[i].idx = startChunk.idx + replacementTextLength;
                            replacementTextLength += replacementChunks[i].txt.length;
                        }
                    }

                    // Insert the new chunks in place of the old ones - keeping all the formatting intact!
                    var newChunks = [];
                    var newTextLen = 0;
                    var existingChunks = chunks.slice( startChunkIdx, endChunkIdx + 1 );
                    var replIdx = 0, existIdx = 0; // counters in `replacementChunks` & `existingChunks` respectively
                    var replInnerIdx = 0, existInnerIdx = 0; // indices into text chunks
                    while( true ) {

                        // Non-TEXT chunks are formatting/control and always get pushed
                        while( replacementChunks[replIdx] && ( replacementChunks[replIdx].ty !== TEXT ) ) {
                            newChunks.push( replacementChunks[replIdx] );
                            replIdx++;
                        }
                        while( existingChunks[existIdx] && ( existingChunks[existIdx].ty !== TEXT ) ) {
                            newChunks.push( existingChunks[existIdx] );
                            existIdx++;
                        }

                        if( newTextLen >= match[0].length ) {
                            break;
                        }

                        // Pick the shorter chunk, so as not to miss any formatting.
                        var replEndIdx = ( replIdx < replacementChunks.length )
                            ? ( replacementChunks[replIdx].idx + replacementChunks[replIdx].txt.length )
                            : Infinity;
                        var existEndIdx = ( existIdx < existingChunks.length )
                            ? ( existingChunks[existIdx].idx + existingChunks[existIdx].txt.length )
                            : Infinity;
                        var usingRepl = replEndIdx <= existEndIdx;
                        if( usingRepl ) {
                            var newText = replacementChunks[replIdx].txt.substring( replInnerIdx );
                            newChunks.push( { ty: TEXT, txt: newText, idx: startChunk.idx + newTextLen } );
                            newTextLen += newText.length;
                            replInnerIdx = 0;
                            while( true ) {
                                replIdx++;
                                if( !replacementChunks[replIdx] || ( replacementChunks[replIdx].ty === TEXT ) ) break;
                                newChunks.push( replacementChunks[replIdx] );
                            }
                            existInnerIdx += newText.length;
                            for( ; existIdx < existingChunks.length; existIdx++ ) {
                                if( existingChunks[existIdx].ty !== TEXT ) {
                                    newChunks.push( existingChunks[existIdx] );
                                } else if( existInnerIdx >= existingChunks[existIdx].txt.length ) {
                                    existInnerIdx -= existingChunks[existIdx].txt.length;
                                } else {
                                    break;
                                }
                            }
                        } else {
                            var newText = existingChunks[existIdx].txt.substring( existInnerIdx );
                            newChunks.push( { ty: TEXT, txt: newText, idx: startChunk.idx + newTextLen } );
                            newTextLen += newText.length;
                            existInnerIdx = 0;
                            while( true ) {
                                existIdx++;
                                if( !existingChunks[existIdx] || ( existingChunks[existIdx].ty === TEXT ) ) break;
                                newChunks.push( existingChunks[existIdx] );
                            }
                            replInnerIdx += newText.length;
                            for( ; replIdx < replacementChunks.length; replIdx++ ) {
                                if( replacementChunks[replIdx].ty !== TEXT ) {
                                    newChunks.push( replacementChunks[replIdx] );
                                } else if( replInnerIdx >= replacementChunks[replIdx].txt.length ) {
                                    replInnerIdx -= replacementChunks[replIdx].txt.length;
                                } else {
                                    break;
                                }
                            }
                        }
                    }

                    // Now, splice the new chunks in place of the old ones
                    var spliceArgs = [ startChunkIdx, endChunkIdx - startChunkIdx + 1 ].concat( newChunks );
                    Array.prototype.splice.apply( chunks, spliceArgs );
                }
            } while( match );
        }

        // Write out chunks into text
        var html = "";
        var activeTag = "";
        for( var i = 0; i < chunks.length; i++ ) {
            var chunk = chunks[i];
            switch( chunk.ty ) {
                case TEXT: html += chunk.txt; break;
                case INS_START: html += "<ins" + DC_CLS + ">"; activeTag = "ins"; break;
                case INS_END: html += "</ins>"; activeTag = ""; break;
                case DEL_START: html += "<del" + DC_CLS + ">"; activeTag = "del"; break;
                case DEL_END: html += "</del>"; activeTag = ""; break;
                case A_START:
                    if( activeTag ) {
                        html += "</" + activeTag + ">";
                    }
                    html += "<a class='fancy-diffs' href=\"" + chunk.url + "\">";
                    if( activeTag ) {
                        html += "<" + activeTag + DC_CLS + ">";
                    }
                    break;
                case A_END:
                    if( activeTag ) {
                        html += "</" + activeTag + ">";
                    }
                    html += "</a>";
                    if( activeTag ) {
                        html += "<" + activeTag + DC_CLS + ">";
                    }
                    break;
                case EXPAND:
                    html += '<span class="fd-expand" data-' + chunk.expandTy + '="' + chunk.data + '">(show)</span>';
                    break;
            }
        }

        return html;
    }

    function processDiff( diffTable ) {
        if( !diffTable.querySelector ) {

            // Assume diffTable is a jQuery object
            diffTable = diffTable.get( 0 );
        }

        if( diffTable.getElementsByClassName( "fancy-diffs" ).length > 0 ) {

            // We already ran on this diff
            return;
        }

        // Determine page name, because processText wants it
        var pageName;
        switch( mw.config.get( "wgCanonicalSpecialPageName" ) ) {
            case "Contributions":
                pageName = diffTable.parentNode.querySelector( "a.mw-contributions-title" ).textContent;
                break;
            case "Watchlist":
                pageName = diffTable.previousElementSibling.querySelector( "a.mw-changeslist-title" ).textContent;
                break;
            default:
                pageName = mw.config.get( "wgPageName" );
                break;
        }

        var rows = diffTable.querySelectorAll( "tr" );
rowLoop:
        for( var rowIdx = 0, numRows = rows.length; rowIdx < numRows; rowIdx++ ) {
            var row = rows[rowIdx];
            if( row.tagName.toLowerCase() === "colgroup" ) {
                return;
            }
            if( row.querySelector( "a" ) ) {
                continue;
            }
            for( var cellIdx = 0, numCells = row.children.length; cellIdx < numCells; cellIdx++ ) {
                var td = row.children[cellIdx];
                if( td.className.indexOf( "diff-context" ) >= 0 ) {
                    if( td.children && td.children.length ) {
                        var text = processText( td.children[0].innerHTML, pageName );
                        td.children[0].innerHTML = text;
                        row.children[cellIdx + 2].innerHTML = text;
                        continue rowLoop;
                    }
                } else if( ( td.className.indexOf( "diff-addedline" ) >= 0 ) ||
                        ( td.className.indexOf( "diff-deletedline" ) >= 0 ) ) {
                    if( td.children && td.children.length ) {
                        td.children[0].innerHTML = processText( td.children[0].innerHTML, pageName );
                    }
                }
            }
        }

        var expandSpans = diffTable.querySelectorAll( "span.fd-expand" );
        for( var spanIdx = 0, numSpans = expandSpans.length; spanIdx < numSpans; spanIdx++ ) {
            var span = expandSpans[spanIdx];
            span.addEventListener( "click", function () {
                if( !this.nextElementSibling || this.nextElementSibling.tagName.toLowerCase() !== "div" || this.nextElementSibling.className !== "fd-img" ) {
                    api.get( {
                        action: "query",
                        titles: this.dataset.img,
                        prop: "imageinfo",
                        iiprop: "url"
                    } ).done( function ( data ) {
                        if( data.query && data.query.pages ) {
                            var url = data.query.pages[ Object.keys( data.query.pages )[0] ].imageinfo[0].url;
                            var div = document.createElement( "div" );
                            div.className = "fd-img";
                            var img = document.createElement( "img" );
                            img.className = "fancy-diffs";
                            img.src = url;
                            img.style["max-width"] = "100%";
                            div.appendChild( img );
                            this.parentNode.insertBefore( div, this.nextSibling );
                        }
                    }.bind( this ) );
                    this.textContent = "(hide)";
                } else {
                    if( this.nextElementSibling.style.display === "none" ) {
                        this.nextElementSibling.style.display = "";
                        this.textContent = "(hide)";
                    } else {
                        this.nextElementSibling.style.display = "none";
                        this.textContent = "(show)";
                    }
                }
            } );
        }
    }

    $.when(
        $.ready,
        mw.loader.using( [ "mediawiki.api", "mediawiki.util" ] )
    ).then( function () {
        var table = document.querySelector( "table.diff" );
        api = new mw.Api();
        mw.util.addCSS( ".fd-expand { cursor: pointer; text-decoration: underline; background-color: #faf3; }" );
        if( table ) {
            processDiff( table );
        }
        mw.hook( "wikipage.diff" ).add( processDiff );
        mw.hook( "new-diff-table" ).add( processDiff );
        mw.hook( "diff-update" ).add( processDiff );
    } );
} )();