3

When visiting an individual post revision page, this script adds:

  • buttons that allow switching to a view showing how the revision differed from the previous one, just like on a full post history page;
  • link to the raw Markdown code of the revision ;
  • link to the “edit this revision” page;
  • links to the previous and next revision’s pages.

On user activity pages (/user/⟨…⟩?tab=activity), it also adds "(revision link)" links next to revision actions, which link to the revision page. (This can be disabled by changing ENABLE_REVTAB to false at the top of the script.)

Pretty hacky; some scraping involved. May break as the site is updated. Other than the diff itself, it incurs one cacheable script request and a number of additional HTML page requests per revision page view (see “Quirks and limitations” below). Consider this script an elaborate feature request (viz; see also direct inspiration post).

Quirks and limitations

  • All interface labels within the script are in English, so they may look jarring on non-English-language sites. The script makes no attempt to adapt to the host site’s language, although it will preserve all interface labels in the HTML it obtains. This does not impact the script’s functionality otherwise.

  • If a question revision edits both its tags and body, a tag diff will appear below the “contents only” view, which already contains a tag list. (This is because the tag diff is not held inside a tab.) This may be remedied in a later version. The tag diff is still present in the DOM, but is made invisible by a CSS hack. (Might still revisit this.)

  • In a number of situations, a post revision number may need to be mapped to a revision UUID or vice versa. This is performed by scraping (i) the “(suggest) edit” page, and when that is unavailable, (ii) the revision history pages. (Doing the same with the API would not work on pages viewable by the user but not publicly, and would require a similar scrubbing algorithm anyway.) The number of post history pages requested will vary depending on the post:

    • In the best case of a recent edit or few revisions, just the first page will be downloaded;
    • Usually, for posts with few non-edit actions in their history, it should perform at most two additional requests, thanks to heuristic estimation of the page number;
    • In a hard case, where the post history has many non-edit actions that throw off the heuristic, several pages may be requested as the estimate is successively refined (“scrubbing”);
    • In a pathologically bad case, where most history pages contain no edit revisions at all, it may even download every single page of the revision history one after another (“drifting”).

    Nevertheless, the mapping will still be obtained in every case; the script does not give up until it determines the revision is not present in the history at all (“snipe hunt” or “derailed”). Discovered UUID mappings (even those opportunistically encountered during scraping) are cached in script storage for a period of 72 hours, to avoid making repetitive requests when visiting multiple revisions of the same post.

Script code

// ==UserScript==
// @name     Stack Exchange: enhanced revision view
// @run-at   document-start
// @match    https://*.stackexchange.com/*
// @match    https://*.superuser.com/*
// @match    https://*.stackoverflow.com/*
// @match    https://*.mathoverflow.net/*
// @match    https://*.serverfault.com/*
// @match    https://*.askubuntu.com/*
// @match    https://stackapps.com/*
// @exclude  https://chat.stackexchange.com/*
// @exclude  https://api.stackexchange.com/*
// @exclude  https://data.stackexchange.com/*
// @exclude  https://openid.stackexchange.com/*
// @exclude  https://area51.stackexchange.com/*
// @exclude  https://stackexchange.com/*
// @exclude  https://contests.stackoverflow.com/*
// @exclude  /^https?:\/\/winterbash\d{4,}\.stackexchange\.com\//
// @grant    GM.getValue
// @grant    GM.setValue
// @grant    GM.deleteValue
// @grant    GM.listValues
// @grant    GM.registerMenuCommand
// ==/UserScript==

const FORCE_SCRAPE = false;
const ENABLE_REVTAB = true;

// we scrape the DOM instead of using api.stackexchange.com so that
// this works even for deleted revisions which the user has the privilege
// of viewing

const catchErrors = e => {
  console.warn(`[SE:ERV] `, e);
};

const localCache = new Map();

const getFromCache = async key => {
  if (FORCE_SCRAPE)
    return void 0;
  let val;
  if ((val = localCache.get(key)) !== void 0)
    return val;
  val = await GM.getValue(`cache.value#${key}`);
  if (val != null) {
    GM.setValue(`cache.stamp#${key}`, Date.now()).catch(catchErrors);
  }
  return val;
};

const putIntoCache = async (key, val) => {
  localCache.set(key, val);
  const prom = GM.setValue(`cache.value#${key}`, val);
  GM.setValue(`cache.stamp#${key}`, Date.now()).catch(catchErrors);
  return await prom;
};

(async () => {
  // remove stale and invalid cache entries

  const keys = await GM.listValues();

  let flushed = 0;

  await Promise.all(keys.map(async key => {
    const m = /^cache\.stamp#(.*)/.exec(key);
    if (!m) {
      const mm = /^cache\.value#(.*)/.exec(key);
      if (mm) {
        if (keys.includes(`cache.stamp#${mm[1]}`))
          return;
        console.warn(`[SE:ERV] deleting unstamped storage key ${key}`);
      } else {
        console.warn(`[SE:ERV] deleting stray storage key ${key}`);
      }

      await GM.deleteValue(key);
      return;
    }

    const stamp = await GM.getValue(key);
    if (Date.now() - stamp > 3 * 86400_000) {
      await Promise.all([
        GM.deleteValue(`cache.value#${m[1]}`),
        GM.deleteValue(`cache.stamp#${m[1]}`),
      ]);
      flushed++;
    }
  }));

  if (flushed > 0) {
    console.info(`[SE:ERV] ${flushed} stale cache entries removed`);
  }
})().catch(catchErrors);

const fetchDOM = (url) => new Promise((ok, ko) => {
  const xhr = new XMLHttpRequest();
  xhr.responseType = 'document';
  xhr.onload = () => {
    ok(xhr.response);
  };
  xhr.onerror = (ev) => {
    ko(ev);
  };
  xhr.onabort = (ev) => {
    ko(ev);
  };
  xhr.open('GET', `${url}`);
  xhr.send(null);
});

const scrapeRevisions = async (postId, compare) => {
  let mm;

  console.log(`[SE:ERV] (#${postId}) scraping revision history`);

  const getRevNum = (node) => {
    for (const link of node.querySelectorAll(':any-link[href^="/revisions/"]')) {
      const m = /^\/revisions\/(\d+)\/(\d+)$/u.exec(new URL(link.href).pathname);
      if (m)
        return m[2];
    }
  };

  let page = 1;
  let pageCount = 0;
  let lastDelta = 1;
  let minPage = 0;
  let maxPage = Infinity;
  let sumPages = 0;
  let sumSpread = 0;

  for (;;) {
    console.log(`[SE:ERV] (#${postId}) scrubbing: min=${minPage} < page=${page} < max=${maxPage}`);

    const revsPageN = await fetchDOM(
      new URL(`/posts/${postId}/revisions?page=${page}`, location));
    const revsPageNRevs = revsPageN.querySelectorAll('.js-revision');

    let allEarly = Infinity;
    let allLate  = Infinity;
    let minKeysLocal = Infinity;
    let maxKeysLocal = -Infinity;
    let result;

    for (const node of revsPageNRevs) {
      const nodeRevNum = getRevNum(node);
      if (!nodeRevNum)
        continue;

      const uuid = node.querySelector('[data-guid]').dataset.guid;

      // not 'time > .relativetime' because edit-approved actions
      // do not use the <time> element in their revision-list markup,
      // it turns out
      const timestamp = Date.parse(node.querySelector('.relativetime[title]')?.title);

      const uuidKey = `${location.origin}/revisions/${uuid}`;
      const rnumKey = `${location.origin}/revisions/${postId}/${nodeRevNum}`;

      const context = {
        num: nodeRevNum,
        uuid,
        uuidKey,
        rnumKey,
        timestamp,
      };

      putIntoCache(rnumKey, uuidKey);
      putIntoCache(uuidKey, rnumKey);

      const m = compare(context);
      if (m.result != null) {
        result = m.result;
      }
      if (m.delta != null) {
        allEarly = Math.min(allEarly, Math.max(0, +m.delta));
        allLate  = Math.min(allLate , Math.max(0, -m.delta));
      }
      if (m.key != null) {
        minKeysLocal = Math.min(minKeysLocal, m.key);
        maxKeysLocal = Math.max(maxKeysLocal, m.key);
      }
    }

    if (result != null)
      return result;

    if (!pageCount) {
      pageCount = Math.max(...[].map.call(
        revsPageN.querySelectorAll('.js-revisions + .pager .js-pagination-item:any-link'),
        node => +new URL(node.href).searchParams.get('page'))
                          );

      if (pageCount < 1) {
        console.error(`[SE:ERV] (#${postId}) no revision pages`);
        break;
      }

      maxPage = Math.min(maxPage, pageCount + 1);
      console.log(`[SE:ERV] (#${postId}) max pages refined to ${maxPage}`);
    }

    sumPages++;
    if (maxKeysLocal >= minKeysLocal) {
      sumSpread += maxKeysLocal - minKeysLocal;
    }
    const density = sumPages / sumSpread;

    console.log(`[SE:ERV] (#${postId}) key density is ${density} pages/unit of spread`);

    if (allEarly && 1 / allEarly) {
      maxPage = page;
      page -= Math.max(1, Math.ceil(allEarly * density));
      page = Math.max(page, minPage + 1);
      lastDelta = -1;
    } else if (allLate && 1 / allLate) {
      minPage = page;
      page += Math.max(1, Math.ceil(allLate * density));
      page = Math.min(page, maxPage - 1);
      lastDelta = 1;
    } else if (!allEarly && !allLate) {
      console.error(`[SE:ERV] (#${postId}) snipe hunt: min=${minPage} < page=${page} < max=${maxPage}`);
      break;
    } else {
      console.warn(`[SE:ERV] (#${postId}) drifting: min=${minPage} < page=${page} < max=${maxPage}`);
      if (page - 1 === minPage)
        page++, minPage++;
      else if (page + 1 === maxPage)
        page--, maxPage--;
      else
        page += lastDelta;
    }

    if (!(minPage < page && page < maxPage)) {
      console.error(`[SE:ERV] (#${postId}) derailed: min=${minPage} < page=${page} < max=${maxPage}`);
      break;
    }
  }
};

const fetchRevisionMapping = async (postId) => {
  if (FORCE_SCRAPE)
    return void 0;

  const editPage = await fetchDOM(`${location.origin}/posts/${postId}/edit`);
  const revisionsListNode = editPage.querySelector('select#revisions-list');

  if (!revisionsListNode)
    return void 0;

  const opts = revisionsListNode.querySelectorAll('option')
  let i = opts.length;

  const newCacheEntries = Object.create(null);

  for (const opt of opts) {
    const num = i--;
    const uuidKey = `${location.origin}/revisions/${opt.value}`;
    const rnumKey = `${location.origin}/revisions/${postId}/${num}`;

    putIntoCache(rnumKey, uuidKey);
    putIntoCache(uuidKey, rnumKey);

    newCacheEntries[rnumKey] = uuidKey;
    newCacheEntries[uuidKey] = rnumKey;
  }

  return newCacheEntries;
};

const getRevisionUuid = async (postId, revNum) => {
  let mm;

  const cacheKey = `${location.origin}/revisions/${postId}/${revNum}`;
  let val = await getFromCache(cacheKey);
  if (val == null) {
    const rmap = await fetchRevisionMapping(postId);
    if (rmap) {
      val = rmap[cacheKey];
    } else {
      val = await scrapeRevisions(postId, ({ uuidKey, num }) => {
        if (num === revNum)
          return { result: uuidKey, delta: 0, key: num };
        return { delta: revNum - num, key: num };
      });
    }
  }

  const m = /\/revisions\/([0-9A-Fa-f\-]+)$/u.exec(val);

  return m[1];
};

const getRevisionNumber = async (postId, revUuid, revTimestamp) => {
  let mm;

  const cacheKey = `${location.origin}/revisions/${revUuid}`;

  let val = await getFromCache(cacheKey);
  if (val == null) {
    const rmap = await fetchRevisionMapping(postId);
    if (rmap) {
      val = rmap[cacheKey];
    } else {
      val = await scrapeRevisions(postId, ({ rnumKey, uuid, timestamp }) => {
        if (uuid === revUuid)
          return { result: rnumKey, delta: 0, key: timestamp };
        return { delta: revTimestamp - timestamp, key: timestamp };
      });
    }
  }

  const m = /\/revisions\/(\d+)\/(\d+)$/u.exec(val);

  if (m[1] !== postId) {
    console.warn(`[SE:ERV] post ID does not match: ${m[1]} ≠ ${postId}`);
  }

  return m[2];
};

const nodeBySelector = (selector, base = document) => new Promise((ok, ko) => {
  const node = base.querySelector(selector);
  if (node) {
    ok(node);
    return;
  }

  switch (document.readyState) {
    case 'complete':
    case 'interactive':
      ok(null);
      return;
  }

  const observer = new MutationObserver((muts, self) => {
    const node = base.querySelector(selector);
    if (node) {
      ok(node);
      self.disconnect();
    }
  });

  observer.observe(base, {
    subtree: true,
    childList: true,
    attributes: true,
  });

  document.addEventListener('DOMContentLoaded', () => {
    const node = base.querySelector(selector);
    ok(node);
    observer.disconnect();
  }, { once: true });
});

const documentHead = nodeBySelector('head');

const loadScript = url => {
  const script = document.createElement('script');
  script.src = url;
  script.defer = true;
  script.async = true;
  return new Promise((ok, ko) => {
    script.onload = () => {
      ok();
    };
    script.onerror = (ev) => {
      ko(new TypeError('network error: failed to fetch'));
    };
    documentHead.then(head => {
      head.appendChild(script);
      script.remove();
    }, ko);
  });
};

const html = (() => {
  const html = function html(pieces, ...values) {
    if (new.target)
      return Reflect.construct(String, arguments, new.target);
    return new html(
      String.raw({ raw: pieces }, ...values.map(
        s => s instanceof html
        ? s
        : `${s}`.replaceAll(/[<>&"]/ug, c => `&#${c.charCodeAt(0)};`))));
  }
  Object.setPrototypeOf(html.prototype, String.prototype);
  return html;
})();

(async () => {
  let m;
  if (!(m = /^\/revisions\/(\d+)\/(\d+)$/u.exec(location.pathname)))
    return;

  const [, postId, revNum] = m;

  const tasks = [];
  const spawn = afunc => void tasks.push(afunc());

  try {
    spawn(async () => {
      /* HACK: hide tag diff when not viewing a diff page */
      (await documentHead).insertAdjacentHTML('beforeend', html`
        <style type="text/css">
          .diffs > .--erv-contents:not(.d-none) ~ :last-child:not(.js-diff-panel) {
            display: none;
          }
        </style>
      `);
    });

    spawn(async () => {
      await nodeBySelector('body');
      const locale = unsafeWindow.StackExchange.options.locale;
      await loadScript(`//cdn.sstatic.net/Js/revisions.${locale}.js`);
      unsafeWindow.styleCode ??= unsafeWindow.Function('');
      unsafeWindow.StackExchange.revisions.init();
    });

    const revUuid = await getRevisionUuid(postId, revNum);
    if (!revUuid) {
      console.error(`[SE:ERV] revision ${revNum} of post ${postId} not found`);
      return;
    }
    console.info(`[SE:ERV] revision ${revNum} of post ${postId} identified as ${revUuid}`);

    spawn(async () => {
      const barFrag = document.createRange().createContextualFragment(html`
        <div class="d-flex gs8 s-anchors s-anchors__muted fw-wrap pt8">
          <a href="/revisions/${revUuid}/view-source" class="flex--item" title="view raw text of this revision" target="_blank">Source</a>
          <a href="/posts/${postId}/edit/${revUuid}" class="flex--item" title="edit this revision">Edit</a>
        </div>
      `);

      const editSummaryNode = await nodeBySelector(`
        #content > .subheader + * > .fs-headline1 + *
      `);

      editSummaryNode.insertBefore(
        barFrag,
        await nodeBySelector('.d-none.md\\:d-block', editSummaryNode)
      );
    });

    spawn(async () => {
      const revHeaderNode = await nodeBySelector(`
        #content > .subheader + * > .fs-headline1
      `);

      const [[r0], [r1]] = Array.from(`${revHeaderNode.textContent}`.matchAll(/\d+/ug));

      let prevLink = '';
      if (revNum !== '1')
        prevLink = html`<a href="/revisions/${postId}/${+revNum - 1}">🡄</a>`;
      let nextLink = '';
      if (r0 !== r1)
        nextLink = html`<a href="/revisions/${postId}/${+revNum + 1}">🡆</a>`;

      revHeaderNode.insertAdjacentHTML('beforeend', html`
        <div style="font-size: 1rem; text-align: center;">
            ${prevLink}
          ${nextLink}
        </div>
      `);
    });

    // do not insert diff if revision number is 1
    if (revNum === '1')
      return;

    spawn(async () => {
      const resp = await fetch(new URL(`/revisions/${postId}/${revUuid}/diff?full=false`, location));
      const frag = document.createRange().createContextualFragment(await resp.text());
      const fragDiffs = frag.querySelector('.diffs');

      if (fragDiffs.matches(':only-child') && !fragDiffs.querySelector('*'))
        return;    //  [Edit removed during grace period]

      const fragTabs = (() => {
        const fragTabs = frag.querySelector('.js-diff-choices');

        if (!fragTabs) {
          // probably a (tag-and/or-title)-only edit
          const hasTitleDiff = !!fragDiffs.querySelector('.fs-title');
          const hasTagDiff = !!fragDiffs.querySelector('.post-tag');

          const [label, hint] = [
            ['Diff'                 , 'Show edits made'], // fallback
            ['Tag diff'             , 'Show edits to the tag list'],
            ['Title diff'           , 'Show edits to the title list'],
            ['Title and tag diff'   , 'Show edits to the title and tag list'],
          ][hasTitleDiff * 2 + hasTagDiff];

          fragDiffs.insertAdjacentHTML('beforebegin', html`
            <div class="mb24">
              <div class="js-diff-choices s-btn-group"
              role="tablist"
              data-controller="s-navigation-tablist"
              aria-label="Comparison modes">
                <button
                  type="button" role="tab"
                  class="s-btn s-btn__muted s-btn__xs action"
                  id="tab-${revUuid}-Mini"
                  aria-controls="panel-${revUuid}-Mini"
                  tabindex="0" title="${hint}">
                  ${label}
                </button>
              </div>
            </div>
          `);

          fragDiffs.insertAdjacentHTML('afterbegin', html`
            <div
              id="panel-${revUuid}-Mini" role="tabpanel"
              aria-labelledby="tab-${revUuid}-Mini"
              class="js-diff-panel mb16 d-none">
            </div>
          `);

          const container = fragDiffs.firstElementChild;
          while (container.nextSibling)
            container.appendChild(container.nextSibling);

          return fragDiffs.previousElementSibling.querySelector('.js-diff-choices');
        }

        return fragTabs;
      })();

      for (const node of fragTabs.querySelectorAll(':scope > [role="tab"]')) {
        node.classList.remove('is-selected');
        node.removeAttribute('aria-selected');
      }

      for (const node of fragDiffs.querySelectorAll(':scope > [role="tabpanel"]')) {
        node.classList.add('d-none');
      }

      fragTabs.insertAdjacentHTML('afterbegin', html`
        <button
          id="tab-${revUuid}-Contents"
          role="tab"
          class="s-btn s-btn__muted s-btn__xs action is-selected"
          tabindex="-1"
          title="Show just this revision's contents, no diff against the previous revision"
          aria-controls="panel-${revUuid}-Contents"
          aria-selected="true">Contents only</button>
      `);

      fragDiffs.insertAdjacentHTML('afterbegin', html`
        <div
          id="panel-${revUuid}-Contents"
          role="tabpanel"
          class="js-diff-panel mb16 --erv-contents"
          aria-labelledby="tab-${revUuid}-Contents"></div>
      `);
      const baseDest = fragDiffs.firstElementChild;

      let anchor = (await nodeBySelector(`
        #content > h1.fs-title,
        #content > .s-prose
      `)).previousSibling;
      const limit = await nodeBySelector('#content > .jc-end');
      const contentNode = limit?.parentNode ?? await nodeBySelector('#content');

      for (;;) {
        const node = anchor;
        if (node === limit)
          break;
        anchor = anchor.nextSibling;
        baseDest.appendChild(node);
      }

      contentNode.insertBefore(frag, limit);
    });
  } finally {
    await Promise.all(tasks);
  }
})().catch(e => {
  console.error(`[SE:ERV] `, e);
});

if (ENABLE_REVTAB &&
    /^\/users\/\d+/.test(location.pathname) &&
    new URLSearchParams(location.search).get('tab') === 'activity')
{
  const extractExpandable = (node) => {
    const metadataNode = node.firstElementChild;

    const postLinkNode = metadataNode.querySelector(':any-link:is(.question-hyperlink, .answer-hyperlink)');
    if (!postLinkNode)
      return void 0;

    const loadUrlNode = metadataNode.querySelector('[data-load-url]');
    if (!loadUrlNode)
      return void 0;

    const timestampNode = metadataNode.querySelector('time > .relativetime[title]');
    if (!timestampNode)
      return void 0;

    const timestamp = Date.parse(timestampNode.title);
    const postURL = new URL(postLinkNode.href);
    const loadURL = new URL(loadUrlNode.dataset.loadUrl);

    let m;

    if (m = /^\/posts\/(\d+)\/body$/u.exec(loadURL.pathname)) {
      return { postId: m[1], revNum: 1, timestamp };
    }
    else if (m = /^\/revisions\/([0-9a-f-]+)$/u.exec(loadURL.pathname)) {
      const revUuid = m[1];
      let postId;

      if (m = /^\/questions\/\d+\/[^/]+\/(\d+)/u.exec(postURL.pathname)) {
        return { postId: m[1], revUuid, timestamp };
      }
      else if (m = /^\/questions\/(\d+)\//u.exec(postURL.pathname)) {
        return { postId: m[1], revUuid, timestamp };
      }
    }

    console.warn(`[SE:ERV] failed to scrape `, node);
    return null;
  };

  const expandablesProcessed = new WeakSet();

  const processExpandable = async node => {
    if (expandablesProcessed.has(node))
      return;
    const firstColumn = node.querySelector(':scope > :first-child > :first-child');
    if (!firstColumn)
      return;
    const metadata = extractExpandable(node);
    if (metadata === void 0)
      return;
    expandablesProcessed.add(node);
    if (metadata === null)
      return;

    if (metadata.revNum == null) {
      metadata.revNum = await getRevisionNumber(metadata.postId, metadata.revUuid, metadata.timestamp);
    }

    firstColumn.insertAdjacentHTML(
      'beforeend',
      html`<a href="/revisions/${metadata.postId}/${metadata.revNum}">(revision link)</a>`
    );
  };

  const scanMutationFor = function* (mut, selector) {
    for (const node of mut.addedNodes) {
      if (node.matches?.(selector))
        yield node;
      yield* node.querySelectorAll?.(selector) ?? [];
      const anc = node.closest ? node.closest(selector) : node.parentNode?.closest(selector);
      if (anc)
        yield anc;
    }
  };

  new MutationObserver((muts, self) => {
    const tasks = [];

    try {
      for (const mut of muts) {
        for (const node of scanMutationFor(mut, '.js-post-expandable')) {
          tasks.push(processExpandable(node));
        }
      }
    } finally {
      Promise.all(tasks).catch(e => {
        console.warn(`[SE:ERV] `, e);
      })
    }
  }).observe(document, {
    subtree: true,
    childList: true,
    attributes: true,  
  });

  GM.registerMenuCommand('[SE:ERV] Flush all caches', () => {
    (async () => {
      const keys = await GM.listValues();
      await Promise.all(keys.map(key => GM.deleteValue(key)));
      alert(`[SE:ERV] All caches flushed`);
    })().catch(e => {
      console.warn(`[SE:ERV] failed to flush cache(s): `, e);
    });
  });
}

Test cases

Corner cases and stress tests

0

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.