User:Þjarkur/Highlight recently added text.js

Source: 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.
$.ready.then(function () {
  setTimeout(function () { // Delay to prevent other plugins from clashing
    if (
      mw.config.get('wgAction') !== 'view' ||
      mw.config.get('wgDiffOldId') || // Set on diff pages
      mw.config.get('wgCurRevisionId') !== mw.config.get('wgRevisionId') ||
      mw.config.get('wgNamespaceNumber') === 14 || //Category
      !mw.config.get('wgArticleId') ||
      $('html').hasClass('ve-active') // VisualEditor
    ) return;

    var settings = {
      color: 'rgba(108, 255, 18, 0.09)', // Faint green
      useInMainspace: true,
      ...(window.highlightRecentlyAddedTextSettings || {}),
    }
    if (!settings.useInMainspace && mw.config.get('wgNamespaceNumber') === 0) return;

    /* Find last seen revision */
    var lastSeenRevision = GetLastSeenRevision()
    SaveLastSeenRevision()

    function run() {
      findGoodOldID(oldid => {
        if (oldid == mw.config.get('wgCurRevisionId')) {
          console.log('Not highlighting text, no recent changes')
          return;
        }
        console.log(`Checking changes since https://en.wikipedia.org/wiki/Special:Diff/${oldid}/cur`)
        getOldversion(oldid, function (old_html) {
          $.when(mw.loader.getScript('https://en.wikipedia.org/w/index.php?title=User:%C3%9Ejarkur/Cacycle%27s_diff_(without_omissions).js&action=raw&ctype=text/javascript')).then(function () {
            var old_text = getText($(old_html))
            var new_text = getText($('body').find('.mw-parser-output').clone())
			if($('html').hasClass('ve-active')) return; // VisualEditor has been turned on in the meantime
            var diffHtml = $((new WikEdDiff()).diff(old_text, new_text))
            diffHtml.find('.wikEdDiffDelete').remove()
            console.log(`${diffHtml.find('.wikEdDiffInsert').length} text additions found`)
            highlightCharacters(FindAdditions(diffHtml))
          })
        })
        $('head').append(`<style>.recent_addition { background: ${settings.color}; }</style>`)
      })
    }

    function getOldversion(oldid, callback) {
      var api = new mw.Api();
      api.get({
        action: 'parse',
        oldid: oldid,
        format: 'json'
      }).done(function (data) {
        callback($.parseHTML(data.parse.text['*']))
      }).fail(function (error) {
        console.log(error);
      })
    }

    var ignore = '.reference, .noprint, .mw-cite-backlink, .mw-editsection, .toc, style, script, .navbox, .reply-link-wrapper, .scriptInstallerLink'

    /*
      Convoluted way to find text nodes to match up with our later method
    */
    function getText(html) {
      var returns = ''

      function TraverseAndFindText(input) {
        $(input).contents().each(function () {
          if (this.nodeType === Node.TEXT_NODE) {
            returns += $(this).text()
          } else {
            if (!$(this).is(ignore)) {
              TraverseAndFindText(this)
            }
          }
        })
      }
      TraverseAndFindText(html)
      return returns
    }

    function FindAdditions(input) {
      var returns = []
      TraverseAndFindAdditions(input, false, function (character) {
        returns.push(character)
      })
      return returns
    }

    function TraverseAndFindAdditions(input, isAdding, callback) {
      $(input).contents().each(function () {
        if (this.nodeType === Node.TEXT_NODE) {
          var text = $(this).text()
          text.split('').forEach(t => {
            callback({
              isAdding,
              text: t
            })
          })
        } else {
          var newIsAdding = isAdding
          if ($(this).hasClass('wikEdDiffInsert')) {
            newIsAdding = true
          }
          TraverseAndFindAdditions(this, newIsAdding, callback)
        }
      })
    }

    function escape_html (input) {
      return input.replace(/[\u00A0-\u9999<>\&]/gim, function(i) {
        return '&#'+i.charCodeAt(0)+';';
      });
    }

    function highlightCharacters(characters) {
      var i = 0;
      var stop = false;
      if (!characters.find(i => i.isAdding)) {
        return console.log('No text added since the revision checked')
      }
      characters = characters.filter(i => i.text !== '\n')

      function TraverseAndHighlight(input) {
        if (stop) return;
        $(input).contents().each(function () {
          if (this.nodeType === Node.TEXT_NODE) {
            var text = $(this).text()
            var array = text.split('').map(t => {
              if (stop) return;
              if (t === '\n') {
                return {
                  isAdding: false,
                  text: t,
                }
              }
              if (!characters[i]) {
                console.warn('Went through too many characters!')
                return null;
              }
              if (t !== characters[i].text) {
                console.error('Could not highlight recently changed text')
                console.warn(`Expected "${t}", got "${characters[i].text}"`)
                console.log(`Surrounding: ${characters.map(i => i.text).slice(Math.max(0,i-5),i+5).join('')}`)

                stop = true;
                return null;
              }
              return characters[i++]
            }).filter(Boolean)
            if (stop) return;
            var new_text = array.reduce((output, current) => {
              var lastIndex = output.length - 1
              if (!output[lastIndex]) {
                return [current]
              }
              if (output[lastIndex].isAdding === current.isAdding) {
                output[lastIndex] = {
                  ...output[lastIndex],
                  text: output[lastIndex].text + current.text,
                }
                return output
              } else {
                return [
                  ...output,
                  current,
                ]
              }
            }, []).map(x => {
              if (x.isAdding) {
                return '<span class="recent_addition">' + escape_html(x.text) + '</span>'
              } else {
                return escape_html(x.text)
              }
            }).join('')
            $(this).replaceWith(new_text)
          } else {
            if (!$(this).is(ignore)) {
              TraverseAndHighlight(this)
            }
          }
        })
      }
      TraverseAndHighlight($('body').find('.mw-parser-output'))
    }

    function findGoodOldID(callback) {
      if (lastSeenRevision) {
        /*
          Check that we didn't just submit our own text
        */
        var api = new mw.Api();
        api.get({
          action: 'query',
          prop: 'revisions',
          titles: mw.config.get('wgPageName'),
          rvlimit: '1',
          rvprop: 'user',
          format: 'json',
        }).done(function (data) {
          var pages = data.query.pages
          for (page in pages) {
            var revisions = pages[page].revisions
            /* Only callback if we weren't the most recent editor */
            if (revisions.length === 0 || revisions[0].user != mw.config.get('wgUserName')) {
              callback(lastSeenRevision)
            }
          }
        }).fail(function (error) {
          callback(lastSeenRevision)
          console.log(error);
        })
        return
      }
      /*
        If none, find last 50 edits.
        Only do this for mainspace.
      */
      if (
        mw.config.get('wgNamespaceNumber') !== 0
        // mw.config.get('wgCategories').includes('Non-talk pages that are automatically signed')
      ) {
        return;
      }
      var api = new mw.Api();
      api.get({
        action: 'query',
        prop: 'revisions',
        titles: mw.config.get('wgPageName'),
        rvlimit: '50',
        rvprop: 'ids|timestamp|user|comment|size|tags',
        format: 'json',
      }).done(function (data) {
        var pages = data.query.pages
        for (page in pages) {
          var revisions = pages[page].revisions
          DiscardRevertedEdits(revisions, callback)
        }
      }).fail(function (error) {
        console.log(error);
      })
    }

    /*
      Adapted from [[User:SD0001/hide-reverted-edits.js]]
    */
    function DiscardRevertedEdits(revisions, callback) {
      var lastEditByCurrentUser = revisions.find(r => {
        return r.user == mw.config.get('wgUserName')
      })
      if (lastEditByCurrentUser) {
        return callback(lastEditByCurrentUser.revid)
      }

      var removed = []
      revisions.forEach(function (revision, index) {

        var rgx;
        var comment = (revision.comment && revision.comment.replace(/\[\[[^|]+?\|([^\]]+)\]\]/g, '$1')) || ''

        // Plain MediaWiki undo with untampered edit summary
        if (rgx = /^Undid revision (\d+) by/.exec(comment)) {
          var reverted_rev_id = rgx[1];
          var $reverted_rev = revisions.find(r => r.revid == reverted_rev_id)
          if(!$reverted_rev) return;

          // just to confirm that the edit isn't a partial revert, find the byte count changes for the
          // two edits: if they add up to 0, then this is a full revert (in all likelihood)
          var diffbytes1 = revision.size;
          var diffbytes2 = $reverted_rev.size;

          if (diffbytes1 + diffbytes2 === 0) {
            removed.push(revision.revid)
            removed.push($reverted_rev.revid)
          }

          // 'Restore this version' reverts using Twinkle or popups or pending changes reverts
          // TW: 		Reverted to revision 3234343 by ...
          // popups: 	Revert to revision 34234234 by ...
          // PC tool: Revereted 3 pending edits by Foo and Bar to revision 3243432 by ...
        } else if (rgx = /^Revert(?:ed)? (?:\d+ pending edits? by .*?)?to revision (\d+)/.exec(comment)) {
          var last_good_revision_id = rgx[1];
          removed.push(revision.revid)
          var i = index
          var $rev = revisions[i++]
          if (parseInt(last_good_revision_id) > parseInt($rev.revid) ||
            parseInt(last_good_revision_id) < 100) { // sanity checks
            return true; // revision id given has to be wrong
          }
          while ($rev.revid != last_good_revision_id) {
            removed.push($rev.revid)
            $rev = revisions[i++]
            if ($rev && $rev.length === 0) {
              callback(last_good_revision_id)
              break; // end of page history in current view
            }
          }

        } else {

          var reverted_user;

          // Reverts tagged as "Rollback"
          if (revision.tags.includes('mw-rollback')) {
            reverted_user = revisions[index + 1] ? revisions[index + 1].user : null
          }

          // Twinkle rollbacks
          else if (rgx = /^Reverted (?:good faith|\d+) edits? by (.*?) \(talk\)/.exec(comment)) {
            reverted_user = rgx[1];
            // Old Twinke vandalism rollback
          } else if (rgx = /^Reverted \d+ edits? by (.*?) identified as vandalism/.exec(comment)) {
            reverted_user = rgx[1];

            // STiki vandalism rollbacks, and all reverts using MediaWiki rollback, Huggle, Cluebot have the "Rollback" tag added
            // and hence would have been handled above. The regex checks here are to account for old reverts done before the
            // "Rollback" tag was introduced

            // STiki AGF/normal/vandalism revert
          } else if (rgx = /^Reverted \d+ (?:good faith )?edits? by (.*?) (?:identified as test\/vandalism )?using STiki/.exec(comment)) {
            reverted_user = rgx[1];

            // normal MediaWiki rollback and Huggle rollback
          } else if (rgx = /^Reverted edits by (.*?) \(talk\)/.exec(comment)) {
            reverted_user = rgx[1];

            // ClueBot
          } else if (['ClueBot NG', 'ClueBot'].includes(revision.user)) {
            reverted_user = /^Reverting possible vandalism by (.*?) to version by/.exec(comment)[1];

            // XLinkBot
          } else if (revision.user === 'XLinkBot') {
            reverted_user = /^BOT--Reverting link addition\(s\) by (.*?) to/.exec(comment)[1];
          }

          if (reverted_user) {

            // page history shows compressed IPv6 address (with multiple 0's replaced by ::)
            // though rollback edit summaries use the uncompressed form (though with leading 0's removed)
            if (mw.util.isIPv6Address(reverted_user)) {
              reverted_user = reverted_user.replace(/\b(?:0+:){2,}/, ':').toLowerCase();
            }
            removed.push(revision.revid)
            var i = 0
            var $rev = revisions[i++];
            while ($rev.user === reverted_user) {
              removed.push($rev.revid)
              $rev = revisions[i++];
              if ($rev.length === 0) break; // end of page history (in current view)
            }
          }
        }
      });
      /* Filter out */
      revisions
        .filter(r => !removed.includes(r.revid))
        .reduce((output, current) => {
          if (output.length === 0) {
            return [current]
          }
          var last = output[output.length - 1]
          if (last.user === current.user) {
            output[output.length - 1] = current // Overwrite last
            return output
          } else {
            return [
              ...output,
              current,
            ]
          }
        }, [])
      var last_ten = revisions.slice(0, 10)
      callback(last_ten[last_ten.length - 1].revid)
    }

    function GetLastSeenRevision() {
      return window.localStorage.getItem('last_seen_' + mw.config.get('wgArticleId'))
    }

    function SaveLastSeenRevision() {
      window.localStorage.setItem('last_seen_' + mw.config.get('wgArticleId'), mw.config.get('wgRevisionId'));
    }
    // Reset: window.localStorage.setItem('last_seen_' + mw.config.get('wgArticleId'), '')

    run()

  }, 100)
})