1

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.

Test cases:

Pretty hacky; some scraping involved. May break as the site is updated. Consider this script an elaborate feature request (viz).

// ==UserScript==
// @name     Stack Exchange: revision diff view
// @grant    none
// @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\//
// ==/UserScript==

// 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 getRevisionUuid = async (postId, revNum) => {
  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 REV_SELECTOR = 'button.js-revision-expander';

  const getRevNum = (node) =>
    node.querySelector('.fs-headline1').textContent;

  const scanButton = (revisionsDom) => {
    for (const node of revisionsDom.querySelectorAll(REV_SELECTOR)) {
      if (getRevNum(node) === revNum)
        return node.dataset.guid;
    }
    return null;
  };

  const revsPage1 = await fetchDOM(new URL(`/posts/${postId}/revisions`, location));

  let m;

  if (m = scanButton(revsPage1))
    return m;

  const revsPage1Revs = revsPage1.querySelectorAll(REV_SELECTOR);
  const revsPerPage = revsPage1Revs.length;

  // the revision count can also be read from the viewed revision's page
  // (#content > .subheader:first-child + * > .fs-headline1),
  // but it's only in locale-dependent text and requires the page to be fully loaded
  // and we need to know the number of revisions per history page anyway

  const revLast = getRevNum(revsPage1Revs[0]);

  const page = 1 + Math.floor((revLast - revNum) / revsPerPage);

  const revsPageN = await fetchDOM(
    new URL(`/posts/${postId}/revisions?page=${page}`, location));

  if (m = scanButton(revsPageN))
    return m;
}

const contentLoaded = new Promise((ok, ko) => {
  switch (document.readyState) {
    case 'complete':
    case 'interactive':
      ok();
      return;
    default:
      document.addEventListener('DOMContentLoaded', () => ok(), { once: true });
  }
});

const loadScript = url => new Promise((ok, ko) => {
  const script = document.createElement('script');
  script.src = url;
  script.onload = () => {
    ok();
  };
  script.onerror = (ev) => {
    ko(ev);
  };
  document.head.appendChild(script);
  script.remove();
});

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)};`))));
  }
  html.prototype = Object.create(String.prototype);
  return html;
})();

(async () => {
  const rxPathnameRev = /^\/revisions\/(\d+)\/(\d+)$/u;

  let m;
  if (!(m = rxPathnameRev.exec(location.pathname)))
    return;

  const [, postId, revNum] = m;

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

  (async () => {

    try {
      // should suffice as the Stack Exchange scripts are synchronous
      await contentLoaded;
      await loadScript(`//cdn.sstatic.net/Js/revisions.${unsafeWindow.StackExchange.options.locale}.js`);
      unsafeWindow.styleCode ??= unsafeWindow.Function('');
      unsafeWindow.StackExchange.revisions.init();
    } catch (e) {
      console.warn(`[RDV] revisions.${unsafeWindow.StackExchange.options.locale}.js not loaded:`, e);
    }

  })();

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

  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');

  const fragTabs = (() => {
    const fragTabs = frag.querySelector('.js-diff-choices');
    
    if (!fragTabs) {
      // probably a (tag-and/or-title)-only edit

      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__outlined s-btn__xs action"
              id="tab-${revUuid}-Mini"
              aria-controls="panel-${revUuid}-Mini"
              tabindex="0" title="Show the edits of the title and/or tag list">
                  Title and/or tag diff
                </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__outlined 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"
      aria-labelledby="tab-${revUuid}-Contents"></div>
  `);
  const baseDest = fragDiffs.firstElementChild;

  await contentLoaded;

  let anchor = document.querySelector(`
    #content > h1.fs-title,
    #content > .s-prose
  `).previousSibling;
  const limit = document.querySelector('#content > .jc-end');

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

  document.querySelector('#content').insertBefore(
    frag,
    limit
  );
})();

0

You must log in to answer this question.

Browse other questions tagged .