2

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. Incurs additional two or three HTML page requests and one cacheable script request per revision page view. May break as the site is updated. Consider this script an elaborate feature request (viz).

Limitations: all messages original to the script are in English, so it may look jarring on non-English-language sites. Nevertheless, I have tested it on those and not noticed any other issues.

// ==UserScript==
// @name     Stack Exchange: revision diff 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\//
// ==/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.defer = true;
  script.async = true;
  script.onload = () => {
    ok();
  };
  script.onerror = (ev) => {
    ko(new TypeError('network error: failed to fetch'));
  };
  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;
  
  contentLoaded.then(async () => {
    const locale = unsafeWindow.StackExchange.options.locale;
    await loadScript(`//cdn.sstatic.net/Js/revisions.${locale}.js`);
    unsafeWindow.styleCode ??= unsafeWindow.Function('');
    unsafeWindow.StackExchange.revisions.init();
  }).catch(e => {
    console.warn(`[RDV] revisions.${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
      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"
      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);
})().catch(e => {
  console.error(`[RDV] `, e);
});

0

You must log in to answer this question.

Browse other questions tagged .