User:Yair rand/UserBlind.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.
/*

Hide usernames when arriving through a certain kind of link.
Replace all mentions of usernames with tokens like '[USER #6]'.
Also hide any other identifying details like signature styling.

This is intended to prevent accidental bias. Users could still deliberately go to the page outside userblind mode.
Could ask users to post whether they arrived via having some connection to the user? Better yet, build in commenting gadget which appends a UserBlind marker.
This would depend on the honor system, people could post that themselves manually or even check outside userblind first.

This script is currently very unpolished and probably quite buggy. It's mostly a proof-of-concept.

*/


// Test cases (TODO):
// * "I mention [[User:Foo]] and some words. --[[User talk:Blah]] ..UTC" shouldn't strip the end of the comment.
// * "I, [[User:Foo]], say foo. --[[User:Foo]] (talk) ..UTC"

// TODO: User pages can't have edit or section-edit buttons. They would have to link to the page itself, exposing the user.


// New system: Priority markers. User: is top, followed by Talk/Contribs. High priority can plow through low priority as a sig, but not the reverse. There's still a potential issue of
// pointing to one's own user page in a comment, though... Maybe cap by text size?


// TODO: When viewing #hash, update scroll position after loading.

// TODO: Remove '.title=[USER] - userName' mod before launch.
// TODO: Remove console logs before launch.


// How to deal with userpage redlinks? Clicking on them points to an edit page, so we need a warning, but exposing them as redlinks in hrefs is identifying.

// TODO: Script shouldn't run if in edit mode.

// TODO: Eventually write some tests. Actually, lots of tests.


// IDEA: Work the comment gadget into this, so that users don't need to go to the edit page.
// Even better: Users can type in '[USER #1]' and have this script replace it with the actual username.

function searchToObject( search ) {
  return search ? Object.fromEntries( search.substr( 1 ).split( '&' ).map( x => x.split( '=' ) ) ) : {};
}

function objectToSearch( object ) {
  return '?' + Object.entries( object ).map( ( [ param, value ] ) => param + '=' + value ).join( '&' );
}

function changePath() {
  
}

function isInline( elem ) {
  // return getComputedStyle( elem ).display === 'inline';
  // getComputedStyle doesn't work unless the element is already visible/in the dom.
  // Make a guess based off of common inline element types.
  return [
    'span', 'a', 'b', 'i', 'bdi', 'bdo', 'code', 'em', 's', 'big', 'small',
    'strong', 'sub', 'sup', 'u', 'del', 'ins', 'dir', 'font', 'strike'
  ].includes( elem.nodeName.toLowerCase() );
}

function escapeRegex( x ) {
  return x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&' );
  // ^Use mw.RegExp.escape instead?
}

function tokenToUrl( token ) {
  // TODO.
}


var specialPage = mw.config.get( 'wgFormattedNamespaces' )[ -1 ] + ':UserBlind',
// var specialPage = 'Special:UserBlind',
  userblindAction = 'userblind',
  userToken = [ '[USER #', ']' ],
  userIpToken = [ '[USER #', ' (IP)]' ],
  userParams = [  // =$1
    'user',
    'wpSearchUser',
    'target' // Contributions, EmailUser
  ],
  userPageParams = [ 'page' ]; // =User[_talk]:$1 (TODO);

// 
function init() {
  
  var pageName = mw.config.get( 'wgCanonicalSpecialPageName' ) === 'Badtitle' ?
    // Get title from URL. Can be either /wiki/$1 or ?title=$1
    ( ( location.search.match( /[?&]title=[^?&]+/ ) || [] )[ 0 ] || '' ).slice( '?title='.length ) ||
      // TODO: Set a common wikiPath var, to avoid repeating it so many times.
      // Maybe make a function for stripping it.
      ( location.pathname.startsWith( '/wiki/' ) && location.pathname.substr( '/wiki/'.length ) ) :
    (
      mw.config.get( 'wgAction' ) === 'nosuchaction' &&
        // Use action=userblind when either there's no title or when there's an oldid overriding the title.
        ( ( location.search.match( /[?&]action=userblind(?:[&/]|$)/ ) ) && specialPage + '/...?' ) ||
        mw.config.get( 'wgPageName' )
    );
  
  // console.log( pageName );
  
  if ( pageName.startsWith( specialPage ) ) {
    if ( pageName.startsWith( specialPage + '/' ) ) {
      
      var search = location.search,
        params = searchToObject( search ),
        pathname = location.pathname;
      
      // Load page content, parse, replace
      
      // Strip specialPage from title.
      let url,
        newParams,
        newPathname = pathname,
        wikiPath = '/wiki/',
        // TODO: Move this.
        // userTokenRegex = new RegExp( [ userToken, userIpToken ].map( token => token.map( encodeURIComponent ).map( escapeRegex ).join( '\\d+' ) ).join( '|' ) );
        userTokenRegex = new RegExp( [ userToken, userIpToken ].map( token => token.map( escapeRegex ).join( '\\d+' ) ).join( '|' ) );
      
      function fixTitle( title ) {
        
        // TODO: Figure out decoding issues. When arriving from form, ?title needs decoding, othertimes not necessary.
        // Note that decodeURIComponent will actually break on malformed URIs (eg '%2').
        // Actually, '%' itself is encoded, so it doesn't matter?
        var dTitle = decodeURIComponent( title ),
          newTitle = dTitle.startsWith( specialPage + '/' ) ?
            dTitle.substr( ( specialPage + '/' ).length ) :
            dTitle;
        // var newTitle = title.startsWith( specialPage + '/' ) ?
        //   title.substr( ( specialPage + '/' ).length ) :
        //   title;
        
        // Replace username token with actual username.
        
        newTitle = newTitle.replace( userTokenRegex, token => {
          // TODO.
          var //decodedToken = decodeURIComponent( x ),
            cacheEntries = Object.entries( getCachedDB() ),
            foundValue = cacheEntries.find( ( [ _, value ] ) => value === token );
          
          // console.log( 4943, token, cacheEntries );
          
          if ( foundValue ) {
            return foundValue[ 0 ];
          } else if ( cacheEntries.length ) {
            // No idea what happened here.
            console.log( 'Cache missing usertoken.', token, cacheEntries );
            throw new Error( 'userReplacementString missing' );
          } else {
            // Either sessionStorage was cleared (new tab, closed window...) or someone shared a link...
            document.querySelector( '#mw-content-text' ).innerText = 'ERROR: Cached User DB not found.';
            throw new Error( 'No cached DB' );
          }
        } );
        // console.log( 22, title, newTitle );
        
        newTitle = encodeURIComponent( newTitle ); // Re-encode.
        
        return newTitle;
      }
      
      if ( params.title ) {
        newParams = Object.assign( {}, params, { title: fixTitle( params.title ) } );
      }
      
      if ( params.action && params.action.startsWith( userblindAction ) ) {
        newParams = newParams || params;
        let actionParts = params.action.split( '/' );
        if ( actionParts.length === 2 ) {
          newParams = Object.assign( {}, newParams, { action: actionParts[ 1 ] } );
        } else {
          delete newParams.action;
        }
      }
      
      [].concat( userParams, userPageParams ).forEach( param => {
        if ( params[ param ] ) {
          newParams = newParams || params;
          newParams[ param ] = fixTitle( params[ param ] );
        }
      } );
      
      if ( pathname.startsWith( wikiPath ) ) {
        newPathname = wikiPath + fixTitle( pathname.substr( wikiPath.length ) );
      }
      
      url =
        ( newPathname || pathname ) +
        ( newParams ? objectToSearch( newParams ) : ( search || '' ) ) +
        location.hash;
      
      
      // console.log( 'loading url', url );
      
      // Load content.
      Promise.all( [
        fetch( url )
          .then( data => data.text() ),
        $.ready.then( () => {
          document.querySelector( '#firstHeading' ).innerText = mw.config.get( 'wgPageName' );
          // TODO: Start loading symbol.
          document.querySelector( '#mw-content-text' ).innerText = 'Loading...';
        } ),
        // We're not rebuilding all the styles, but these can be added manually in
        // case we're viewing certain pages.
        mw.loader.using( [
          'mediawiki.diff.styles', // For viewing diffs
          'mediawiki.special.changeslist', 'mediawiki.special', 'mediawiki.interface.helpers.styles', // For viewing changelists
          'mediawiki.action.history.styles' // History pages in particular...
          
          // 'mediawiki.action.history' is the js for history pages. Maybe load that after on those?
        ] )
      ] ).then( html => {
          var frag = ( new DOMParser() ).parseFromString( html, "text/html" ),
            elemsToReplace = [ 'title', '#content', '#left-navigation', '#p-views' ],
            // Maybe instead of contentText, use bodyContent? The user bar at
            // the top of Contribs is outside contentText.
            // TODO: Consider.
            // Or TODO: Rename contentText variable.
            // contentText = frag.getElementById( 'mw-content-text' );
            contentText = frag.getElementById( 'bodyContent' ),
            userBlindIndicator = document.createElement( 'img' ),
            elemsToCrawlForText = [ '#firstHeading', 'title' ].map( selector => frag.querySelector( selector ) ),
            elemsNeedingLinkFix = [ '#left-navigation', '#p-views' ].map( selector => frag.querySelector( selector ) );
          
          // clearUserpageEditLinks( frag ); // TODO
          
          clearUsernames( location.origin + url, contentText, elemsToCrawlForText, elemsNeedingLinkFix );
          
          // TODO: Fix title (<h1>, <title>) on userpages themselves. (Mostly solved.)
          // We already know what the username was.
          // Should replace even if no link on the page, and no ?title= in URL.
          // (Currently only works if title in URL, which diffs sometimes don't have.)
          
          [ ...frag.querySelectorAll( '#ca-edit, #ca-addsection, #ca-watch' ) ].forEach( elem => elem.remove() );
          
          // Unsure of which icon to use...
          userBlindIndicator.src = '//upload.wikimedia.org/wikipedia/commons/thumb/7/79/OOjs_UI_icon_eyeClosed.svg/20px-OOjs_UI_icon_eyeClosed.svg.png';
          // userBlindIndicator.src = '//upload.wikimedia.org/wikipedia/commons/thumb/d/d2/Font_Awesome_5_solid_user-slash.svg/20px-Font_Awesome_5_solid_user-slash.svg.png';
          userBlindIndicator.title = 'UserBlind mode active';
          frag.querySelector( '.mw-indicators' ).appendChild( userBlindIndicator );
          
          elemsToReplace.forEach( id => {
            var oldContent = document.querySelector( id ),
              newContent = frag.querySelector( id );
            
            oldContent.replaceWith( newContent );
          } );
          
          
          // mw.hook( 'wikipage.content' ).fire();
        } );
    } else {
      // On the special page itself. Build a form or something.
      $.ready.then( () => {
        document.querySelector( '#firstHeading' ).innerText = 'Special:UserBlind';
        document.querySelector( '#mw-content-text' ).innerText = 'Go to a subpage of Special:UserBlind to use the script. For example, to see the page WP:ANI in UserBlind mode, go to Special:UserBlind/WP:ANI.';
      } );
    }
  }
}

// TODO:
// * Hide edit button (but maybe not new section button) on userpages.


// Get the highest (in chain) ancestor element which is display: inline; and has siblings.
// (When a sig is at the end of a comment contained in an inline, the container
// should be inside the inline.)
function getContainingStylingElement( elem ) {
  var container = elem,
    parent = elem.parentNode;
  
  for ( ; parent; ) {
    if ( isInline( parent ) ) {
      if ( parent.nextSibling || parent.previousSibling ) {
        container = parent;
      }
      parent = parent.parentNode;
    } else {
      break;
    }
  }
  
  return container;
}

function getCachedDB() {
  // TODO: Don't keep rebuilding this.
  var dbText = sessionStorage.YRUserBlindDB,
    db = dbText ? JSON.parse( dbText ) : {};
    
  return db;
}

function setCachedDB( oldDB, newDB ) {
  sessionStorage.YRUserBlindDB = JSON.stringify( Object.assign( {}, oldDB, newDB ) );
}

function handleEditLink( link ) {
  link.addEventListener( 'click', e => {
    var confirmed = confirm( 'Now exiting UserBlind mode. Continue?' );
    confirmed || e.preventDefault();
    return confirmed;
  } );
}

// TODO: Replace single contentText with an array of elements to clear.
function clearUsernames( locationUrl, contentText, elemsNeedingTextCrawl, elemsNeedingLinkFix ) {
  
  
  
  
  // Clear everything between the user link (or equivalent) and the timestamp,
  // unless there's something distinctly non-signature-like in between.
  function clearSig( link, nextUserLink ) {
    // We assume that a timestamp at the end of a block means it's a signature,
    // and that anything between the last user link and the end is sig.
    
    
    // Possible alternate strategy: Walk forward from link.
    
    var container = link.parentNode,
      timestampNode = container && container.lastChild,
      tsnText,
      timestamp;
    
    // Find the last element which isn't a comment or display: block;
    for ( ; true; ) {
      if ( !timestampNode ) {
        return;
      }
      if (
        timestampNode.nodeType === Node.COMMENT_NODE ||
        ( timestampNode.nodeType === Node.ELEMENT_NODE && !isInline( timestampNode ) )
      ) {
        timestampNode = timestampNode.previousSibling;
      } else if ( timestampNode.nodeType === Node.TEXT_NODE ) {
        break;
      } else {
        return;
      }
    }
    
    if ( nextUserLink ) {
      let nextUserPosition = timestampNode.compareDocumentPosition( nextUserLink );
      if ( nextUserPosition === Node.DOCUMENT_POSITION_PRECEDING ) {
        // This user link isn't part of a sig.
        return;
      }
    }
    
    tsnText = timestampNode.nodeValue;
    timestamp = tsnText.match( / ?\d\d:\d\d, \d\d? [A-Z][a-z]+ 20\d\d \(UTC\)\s?$/ );
    
    if ( !timestamp ) {
      return;
    }
    
    for ( ; link.nextSibling !== timestampNode; ) {
      container.removeChild( link.nextSibling );
    }
    
    if ( tsnText !== timestamp[ 0 ] ) {
      timestampNode.nodeValue = timestamp[ 0 ];
    }
    
    return timestampNode;
  }
  
  function crawlForTextAndHashes() {
    
    var punctReg = '[.,\\/#!$%\\^&\\*;:{}=_`\'\"~()[\\]\\|<>_\\u200E\\u1456-]',
      reg = new RegExp( '(^|\\s|' + punctReg + ')(' + Object.keys( users ).map( x =>
        
        // TODO: IPs are shown with lowercase in text, uppercase in urls. Text crawl
        // should match either, but still be case-sensitive for non-IPs.
        
        // Word breaks don't work for certain characters. Non-Latin words
        // aren't surrounded by \b. Also, the space between latin and non-latin
        // chars count as \b. Can't use it anywhere.
        
        // '\\b' won't work here, because all spaces are replaced with _s in ids.
        // Lookbehinds are unsupported...
        
        escapeRegex( x )
        // decodeURIComponent( escapeRegex( x ) )
          .replace( /[ _]/g, '[ _]' ) // Fix spaces
      ).join( '|' ) + ')(?=$|\\s|' + punctReg + ')', 'g' );
    // console.log( reg );
    
    // Replace a username with a user token.
    function replaceUsernames( text, space ) {
      return text.replace( reg, ( _, prefix, name ) => {
        // Usernames are stored with underscores, but show up in plain text with spaces.
        var cleanName = name.replace( / /g, '_' ),
          // When visible on the page, the token should have a space, but when
          // in a hash, it should use an underscore.
          token = users[ cleanName ];
        
        if ( space !== ' ' && token ) {
          token = token.replace( / /g, space );
        }
        
        if ( !token ) {
          console.error( 'Name not found: ', name, cleanName, users );
        }
        
        return prefix + token;
      } );
    }
    
    // TEXT
    
    // Crawl all text nodes, replace usernames as necessary.
    function crawlElemForText( elem ) {
      if ( elem.nodeType === Node.ELEMENT_NODE ) {
        [ ...elem.childNodes ].forEach( crawlElemForText );
      } else if ( elem.nodeType === Node.TEXT_NODE ) {
        let oldText = elem.nodeValue,
          newText = replaceUsernames( oldText, ' ' );
        if ( oldText !== newText ) {
          elem.nodeValue = newText;
        }
      }
    }
    
    elemsNeedingTextCrawl.forEach( crawlElemForText );
    
    crawlElemForText( contentText );
    
    // HASHES
    
    // Go through all links, replace usernames in hashes and their targets.
    function hashToId( hash ) {
      // For the ID, not for the link.
      // return encodeURIComponent( hash.replace(/ /g,'_') ).replace( /%/g, '.' );
      // return hash.replace(/ /g,'_').replace( /%/g, '.' );
      return decodeURIComponent( hash ).replace(/ /g,'_').replace( /%/g, '.' );
    }
    
    function hashToSelector( hash ) {
      // return hash.replace( /[~!@$%^&*()_+-=,./';:"?><\[\]{}|`#]/g, '\\$&');
      // Turns out this is tremendously complicated. Use built-in CSS.escape, even though not supported by IE and maybe Edge.
      // TODO: Find polyfill.
      // return CSS.escape( hash );
      return CSS.escape( decodeURIComponent( hash ) );
    }
    
    [ ...contentText.querySelectorAll( 'a[href*="#"]') ].forEach( elem => {
      
      var hash = elem.hash,
        // TODO: This doesn't work for encoded usernames. The reg doesn't
        // change usernames to encoded form, so it misses them.
        // Even the punct markers aren't all working, bc it won't match "%22" etc as ws.
        // * Maybe decode then reencode? Would result in encoding of usertoken...
        //   - Maybe leave the usertoken encoded on both ends?
        //     Hashes are currently only dealt with here. Eventually also in processCurrentHash.
        //     Unencoded token looks nicer, I think. Actually, does the address bar
        //     automatically decode? Try that.
        // * Encode the regex? Sounds difficult...
        // (Solved this, I think.)
        
        // This is currently broken on encoded names because of this.
      
      //   newHash = replaceUsernames( hash, '_' );
      // if ( hash !== newHash ) {
      //   elem.hash = newHash;
      //   if ( elem.pathname === location.pathname && elem.search === location.search ) {
      //     // let target = contentText.querySelector( elem.hash.replace( /[%{}()]/g, '\\$&') );
      //     let target = contentText.querySelector( '#' + hashToSelector( hash.substr( 1 ) ) );
      //     console.log( 321, target, target && target.id, hash, newHash, '#' + hashToSelector( hash.substr( 1 ) ) );
      //     if ( target ) {
      //       target.id = hashToId( newHash.substr( 1 ) );
      //     }
      //   }
      // }
      
      // Technically works, haven't seen any problems?
        dHash = decodeURIComponent( hash.replace( /%(?![0-9a-f]{2})/gi, '%25' ) ),
        newHash = replaceUsernames( dHash, '_' );
      if ( dHash !== newHash ) {
        elem.hash = newHash;
        if ( elem.pathname === location.pathname && elem.search === location.search ) {
          // let target = contentText.querySelector( elem.hash.replace( /[%{}()]/g, '\\$&') );
          let target = contentText.querySelector( '#' + hashToSelector( dHash.substr( 1 ) ) );
          // console.log( 321, target, target && target.id, hash, newHash, '#' + hashToSelector( hash.substr( 1 ) ) );
          if ( target ) {
            target.id = hashToId( newHash.substr( 1 ) );
          }
        }
      }
    } );
    
    // TODO: HASHES
    // * Go through every link with a hash, run a regex through it, and change any ids that match changed hrefs. (Done.)
    // * Look through current location.hash for userToken, add token match from cachedDB to users, and reverse to find the original ID to replace with new ID.
    // * After that, elem.scrollIntoView();
    //   (Actually, is scrollIntoView necessary? If the element shows up right after page load, does it work?)
  }
  
  function fixForms( contentText ) {
    var forms = [ ...contentText.querySelectorAll( 'form' ) ];
    
    forms.forEach( form => {
      
      // TODO: Check that form.action matches domain with location.
      
      var actionInput = form.querySelector( 'input[name=action]' ),
        titleInput = form.querySelector( 'input[name=title]' ),
        action = form.action && ( typeof form.action === 'string' ? form.action : form.getAttribute( 'action' ) ),
        pathname;
      try {
        pathname = ( new URL( action ) ).pathname;
      } catch ( e ) {
        try {
          pathname = ( new URL( location.origin + action ) ).pathname;
        } catch ( ee ) {
          // No idea.
          return;
        }
      }
      // console.log( 5332 , action, form.action, form.getAttribute( 'action' ) );
      
      if ( pathname.startsWith( '/wiki/' ) ) {
        let { processedUrl } = processUrl( action );
        
        if ( processedUrl !== action ) {
          form.action = processedUrl;
        }
      }
      
      
      if ( titleInput ) {
        titleInput.value = specialPage + '/' + titleInput.value;
        // Should existing title values be processed and replaced with tokens? TODO: Consider.
      }
      if ( actionInput ) {
        // TODO: Diffs need actionInput mod anyway.
        actionInput.value = userblindAction + '/' + actionInput.value;
      } else {
        actionInput = document.createElement( 'input' );
        actionInput.type = 'hidden';
        actionInput.name = 'action';
        actionInput.value = userblindAction;
        form.appendChild( actionInput );
      }
      
      // Fix certain textboxes
      [ ...form.querySelectorAll( '#mw-target-user-or-ip, #mw-input-page>input, #mw-input-user>input, #mw-input-wpSearchUser>input' ) ].forEach( input => {
        
        // #mw-target-user-or-ip for contribs
        // Also #mw-input-page>input , for Special:Log/block and #mw-input-user>input for Special:Log
        // On history, form#mw-history-compare needs some action fiddling...
        // #mw-target-user-or-ip, #mw-input-page>input, #mw-input-user>input
        
        input.value = ''; // TODO: Replace contents with token.
        
      } );
      
    } );
  }
  
  function processCurrentHash( hash ) {
    // Run regex.
    // userTokenRegex is only available in init. Move this there?
  }
  
  /**
   * Process a URL, noting any usernames in it and providing a processed url
   * with usernames stripped out, and prefixed with the UserBlind mode prefix.
   * This does not process hashes.
   *
   * @param url {String}
   */
  const processUrl = ( () => {
    
    
    var namespaces = mw.config.get( 'wgFormattedNamespaces' ),
      [ userNS, usertalkNS, specialNS ] = [ 2, 3, -1 ].map( i => namespaces[ i ].replace( / /g, '_' ) ),
      
      // Special pages which allow the format Special:$SPECIALPAGE/$USER.
      specials = [
        'Contributions', 'Contribs', 'DeletedContributions', 'Log', 'Block',
        'Unblock', 'EmailUser', 'UserRights', 'ListFiles'
      ], // There are more to add here...
      // Normally Special:Log/$1 means that $1 is the performer, and therefore a user.
      // The exception is when $1 is one of the options below, in which case
      // it's a type, not a user.
      specialSubtypes = {
        'Log': [
          'abusefilter','block','contentmodel','delete','globalauth','gblblock',
          'gblrename','gblrights','import','pagelang','massmessage','merge',
          'move','newsletter','create','pagetranslation','patrol','protect',
          'tag','managetags','thanks','liquidthreads','timedmediahandler',
          'notifytranslators','translationreview','upload','newusers',
          'usermerge','renameuser','rights'
        ]
      };
    
    function processUrl( url ) {
      var urlObject = new URL( url ),
        { pathname, search, hash } = urlObject,
        params = searchToObject( search ),
        newParams,
        linkType = 0,
        isIP,
        userReplacementString;
      
      function setParam( param, value ) {
        newParams = newParams || params;
        // newParams = Object.assign( {}, newParams, { [ param ]: value } ); // Use this instead?
        newParams[ param ] = value;
      }
      
      function processTitle( title, isTarget ) {
        // Return processed value (Special:UserBlind/[USER #1])
        
        // try { decodeURIComponent( title ) } catch( e ) { console.log( 443, title ) };
        
        var dTitle = decodeURIComponent( title ),
          // pTitle = title.replace( /\+/g, '_' ), // TODO.
          titleParts = dTitle.split( '/' ),
          // colonSplit = titleParts[ 0 ].split( ':' ),
          [ ns, pageTitle ] = titleParts[ 0 ].split( /:(.+)/ );
        
        // [ userNS, usertalkNS, specialNS ].includes( ns ) && console.log( 442, dTitle, title );
        
        if ( ns === userNS || ns === usertalkNS ) {
          let isSubPageLink = titleParts.length !== 1, // Subpages are assumed not to be signatures
            isAction = ( ( params.action && ( params.action !== 'edit' || !params.redlink ) ) || params.oldid );
          
          userReplacementString = processUsername( pageTitle );
          
          // Replace...
          title = ns + ':' + encodeURIComponent( userReplacementString ) + ( isSubPageLink ? '/' + titleParts.slice( 1 ).join( '/' ) : '' );
          
          linkType = ( isSubPageLink || isAction ) ?
            linkTypes.USER_RELATED :
            ns === userNS ?
              linkTypes.USER_PAGE :
              linkTypes.USER_TALK;
        } else if ( ns === specialNS ) {
          
          let isSpecialSubtype = specialSubtypes[ pageTitle ] && specialSubtypes[ pageTitle ].includes( titleParts[ 1 ] ), // eg Special:Log/move/[USER]
            hasSub = titleParts.length === ( isSpecialSubtype ? 3 : 2 );
          
          if ( hasSub && specials.includes( pageTitle ) ) {
            // Some special page relating to a user.
            
            if ( isSpecialSubtype ) {
              userReplacementString = processUsername( titleParts[ 2 ] );
              title = ns + ':' + pageTitle + '/' + titleParts[ 1 ] + '/' + encodeURIComponent( userReplacementString );
            } else {
              userReplacementString = processUsername( titleParts[ 1 ] );
              title = ns + ':' + pageTitle + '/' + encodeURIComponent( userReplacementString );
            }
            
            linkType = pageTitle === 'Contributions' ? linkTypes.CONTRIBS : linkTypes.USER_RELATED;
          } else if ( pageTitle === 'UserBlind' ) {
            return title;
          }
        }
        
        return specialPage + '/' + title;
      }
      
      function processUsername( userName ) {
        // Process username, without namespace.
        var userString; // TODO: Rename.
        
        isIP = ipRegex.test( userName );
        
        if ( !users[ userName ] && userName ) {
          if ( cachedDB[ userName ] ) {
            userString = cachedDB[ userName ];
          } else {
            userString = ( isIP ? userIpToken : userToken ).join( ++lastUser );
          }
          users[ userName ] = userString;
        } else {
          userString = users[ userName ];
        }
        
        // userReplacementString = userString;
        
        return userString;
      }
      
      userParams.forEach( param => {
        if ( params[ param ] ) {
          
          // Replace param, get username to fill return value, add to list of usernames.
          // Don't assume we need a text replacement.
          
          // TODO: Make sure the link actually works.
          
          // ?page= for some reason uses + instead of _?
          // ?title= uses _'s...
          
          // Need to strip +s, then re-add them afterward?
          var val = params[ param ].replace( /\+/g, '_' );
          
          setParam( param, encodeURIComponent( processUsername( decodeURIComponent( val ) ) ) );
          linkType = linkTypes.USER_RELATED;
        }
      } );
      
      userPageParams.forEach( param => {
        if ( params[ param ] ) {
          
          // For some reason, these are encoded...
          
          // TODO: Probably don't add special prefix to page= types.
          // TODO: Replace + with _ before processing, so that there aren't two separate names stored.
          var title = params[ param ].replace( /\+/g, '_' ),
            newTitle = processTitle( title, false );
          
          setParam( param, newTitle );
          // Also, don't assume USER_RELATED. Some pages aren't user types at all.
          linkType = linkTypes.USER_RELATED;
          // linkType = ...
        }
        // 
      } );
      
      if ( params.title ) {
        setParam( 'title', processTitle( params.title, true ) );
      }
      
      if ( pathname.startsWith( '/wiki/' ) ) {
        pathname = '/wiki/' + processTitle( pathname.substr( '/wiki/'.length ), true );
      }
      
      if ( params.oldid || params.diff || ( !params.title && !pathname.startsWith( '/wiki/' ) ) ) {
        setParam( 'action', params.action ? userblindAction + '/' + params.action : userblindAction );
      }
      
      
      
      return {
        userReplacementString,
        linkType,
        isIP,
        isEdit: params.action === 'edit',
        processedUrl: 
          pathname +
          ( newParams ? objectToSearch( newParams ) : ( search || '' ) ) +
          ( linkType === linkTypes.USER_TALK && hash === '#top' ? '' : hash )
      };
    }
    
    return processUrl;
  } )();
  
  var users = {},
    cachedDB = getCachedDB(),
    lastUser = Object.keys( cachedDB ).length,
    linkTypes = {
      USER_PAGE: 4,
      USER_TALK: 3,
      CONTRIBS: 2,
      USER_RELATED: 1
    },
    ipRegex = /^\d{1,3}(?:\.\d{1,3}){3}$|^[0-9A-F]{1,4}(?::[0-9A-F]{1,4}){7}$/,
    
    // allLinks = [ ...contentText.querySelectorAll( 'a:not( .extiw )' ) ],
    
    // TODO
    // Actually, fixHashes and fixForms only need to run for contentText.
    // And the title... needs special treatment. Username needs to be parsed specially
    // even if no links.
    // allLinks = elemsToClear.flatMap( elem => [ ...elem.querySelectorAll( 'a:not( .extiw )' ) ] ),
    allLinks = [ contentText ].concat( elemsNeedingLinkFix ).flatMap( elem => [ ...elem.querySelectorAll( 'a:not( .extiw )' ) ] ),
    
    
    // TODO: If we don't have private vars in here anymore, don't do extra encapsulation.
    userLinksData = ( () => {
      
      // Things to hide:
      // * URLs, including (/wiki/|/w/index.php?[...?]title=)User:$1, User talk:$1, Special:Contributions/$1, ?user=$1, wpSearchUser=$1, ?page=User:$1, 
      //   Special:DeletedContributions/$1, Special:Log/move/$1, Special:Block/$1, Special:EmailUser/$1
      
      // User > User_talk/Contribs can be sigs. Others above can interrupt sigs if not from the same user.
      
      return allLinks.flatMap( ( link, i ) => {
        
        
        if ( link.host !== location.host ) {
          // External link, don't modify.
          return [];
        }
        
        if ( link.pathname === location.pathname && link.search === location.search ) {
          // Probably a href="#p-search" or something. Might even include a token in pathname.
          // Skip it, nothing to do with it anyway.
          // console.log( 'skip', link )
          return [];
        }
        
        // * Find what username is associated with the link.
        // * Store the name. (This needs to be done here if we're going to fix the link here.)
        // * Find what kind of link it is. (namespace, special page, etc)
        // * Fix the link href, to replace the username. (This needs to be done here unless we're redoing a lot of stuff later.)
        // * Fix the link to point to userblind/, even if not a user link.
        
        
        // Maybe?
        // processTitle( title,
        //   _linkType => ( linkType = _linkType ), // or = _linkType && linkTypes.USER_RELATED;
        //   processedTitle => setParam( param, processedTitle ),
        //   _userReplacementString => ( userReplacementString = _userReplacementString )
        // );
        // { processedTitle, userReplacementString, linkType } = processTitle( title );
        // setParam( processedTitle );
        
        var { userReplacementString, linkType, isIP, processedUrl, isEdit } = processUrl( link.href );
        
        if ( isEdit && !userReplacementString ) {
          // TODO: How to handle redlinked usernames?
          handleEditLink( link );
          return [];
        }
        
        link.href = processedUrl;
        // --
        
        if ( userReplacementString ) {
          return {
            linkType,
            isIP,
            userReplacementString,
            elem: link
          };
        }
        
        return [];
        
      } );
    } )();
  // console.log( 555, userLinksData );
  
  // For userpages and such, recognize the (original) username in the page title, for fixing 
  // the h1/title elements and in raw text.
  processUrl( locationUrl );
  
  if ( location.hash ) {
    processCurrentHash( location.hash );
  }
  
  userLinksData.forEach( ( linkData, i ) => {
    // Get parent.
    // Check if sig.
    // If sig, clear contents.
    // If sig or username, replace innerText.
    
    var elem = linkData.elem,
      outerUserLink = getContainingStylingElement( elem ),
      nextLink;
      
    for ( let ii = 1; ii < 10; ii++ ) {
      let next = userLinksData[ i + ii ];
      if (
        !next ||
        next.userReplacementString !== linkData.userReplacementString || // We assume that no signature is interrupted by a link to a page relating to another user
        (
          !linkData.isIP ?
            ( linkData.linkType !== linkTypes.USER_PAGE || next.linkType === linkTypes.USER_PAGE ) : // Assume that sigs that contain links to userpages start with them
            ( linkData.linkType !== linkTypes.CONTRIBS || next.linkType === linkTypes.CONTRIBS ) // unless it's an IP, in which case sigs all start with contribs links.
        )
      ) {
        // Not part of the same signature.
        nextLink = next;
        break;
      }
    }
    
    // TODO: Don't replace with userReplacementString unless that was actually
    // in the title. For USER_RELATED based on params, they're not in the title.
    // Also, probably preserve namespace prefixes and such.
    // 
    elem.title = linkData.userReplacementString;
      // + ' - ' + Object.entries( users ).find( ( [ key, value ] ) => value === linkData.userReplacementString )[ 0 ]; // Temporary, for testing
    elem.classList.remove( 'new' ); // TODO: First check?
    
    // elem = parent;
    try {
      var tsn = clearSig( outerUserLink, nextLink && nextLink.elem );
      
      if ( tsn ) {
        outerUserLink.replaceWith( elem );
        
        // Make all signature links point to Contribs.
        // TODO.
        elem.href = '/wiki/' + specialPage + '/' + 'Special:Contributions/' + encodeURIComponent( linkData.userReplacementString );
      }
    } catch ( e ) {
      console.log( elem, e, linkData.userName );
    }
    
    // Clear text if tsn or if in User: space
    if ( tsn || linkData.linkType === linkTypes.USER_PAGE ) {
      if ( linkData.userReplacementString === undefined ) {
        console.log( 4949, elem, linkData.userName );
      }
      elem.innerText = linkData.userReplacementString;
    }
  } );
  
  fixForms( contentText );
  
  if ( Object.keys( users ).length ) {
    // (fixHashes might actually need a different condition, if we start with a
    // hash that has a usertoken in it.)
    // crawlForText();
    // fixHashes();
    crawlForTextAndHashes();
    
    setCachedDB( cachedDB, users );
  }
  
  // console.log( users );
}



init();

// clearUsernames( document.querySelector( '#mw-content-text') );