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. It also adds links to the raw Markdown code of the revision and an edit link.

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.

  • A revision number needs to be mapped to a revision UUID to obtain the diff (and the raw Markdown source link). This is performed by scraping the revision history pages, so that it work even on deleted posts viewable by the user; as a consequence, it requires guessing which page of the post history will contain the revision. (Doing the same with the API would have been more complicated, and would require similar page-guessing anyway, or downloading the whole history.) The number of post history pages requested will vary depending on the post:

    • In the best case of a recent edit, 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 UUID 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 24 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.registerMenuCommand
// ==/UserScript==

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

class Cache {
  #gmKey;
  #expiryTime;
  #cache;
  
  constructor(gmKey, expiryTime) {
    this.#gmKey = gmKey;
    this.#expiryTime = expiryTime;
  }
  
  async #load(force = false) {
    if (!force && this.#cache)
      return this.#cache;
    const cache = await GM.getValue(this.#gmKey) ?? Object.create(null);
    if (!force)
      this.#cache = cache;
    return cache;
  }
  
  #merge(cache, entries) {
    const expires = Date.now() + this.#expiryTime;
    for (const [key, value] of Object.entries(entries)) {
      cache[key] = { value, expires };
    }
  }
  
  #prune(cache) {
    for (const [key, { expires }] of Object.entries(cache)) {
      if (Date.now() >= expires)
        delete cache[key];
    }
  }
  
  async get(key) {
    const cache = await this.#load();
    return cache[key]?.value;
  }
  
  async putMany(entries) {
    const cache = await this.#load(true);
    this.#merge(cache, entries);
    this.#prune(cache);
        await GM.setValue(this.#gmKey, cache);
    this.#cache = cache;
  }
  
  async flush() {
    await GM.deleteValue(this.#gmKey);
  }
}

const uuidCache = new Cache('revision_uuids', 86400_000);

// 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
//
// besides, /posts/{ids}/revisions would require basically
// the same kind of interpolated binary search scrubbing anyway

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

  console.log(`[SE:ERV] locating UUID of revision ${revNum} of post ${postId}`);
  
  if (mm = await uuidCache.get(`${location.origin}/revisions/${postId}/${revNum}`)) {
    console.log(`[SE:ERV] found cached`);
    return mm;
  }
  
  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 getRevNum = (node) => {
    for (const link of node.querySelectorAll(':any-link[href^="/revisions/"]')) {
      const m = rxPathnameRev.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 sumEditRevs = 0;

  for (;;) {
    console.log(`[SE:ERV] 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 result;
    const newCacheEntries = Object.create(null);

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

      const uuid = node.querySelector('[data-guid]').dataset.guid;
      newCacheEntries[`${location.origin}/revisions/${postId}/${nodeRevNum}`] = uuid;

      if (nodeRevNum === revNum)
        result = uuid;
      allEarly = Math.min(allEarly, Math.max(0, revNum - nodeRevNum));
      allLate  = Math.min(allLate , Math.max(0, nodeRevNum - revNum));
    }
    
    await uuidCache.putMany(newCacheEntries);

    if (result)
        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] no revision pages`);
        break;
      }

        maxPage = Math.min(maxPage, pageCount + 1);
    }
    
    const editRevs = [].filter.call(revsPageNRevs, node => !!getRevNum(node)).length;

    sumEditRevs += editRevs;
    sumPages++;
    
    if (allEarly && 1 / allEarly) {
      maxPage = page;
      page -= Math.max(1, Math.ceil(allEarly * sumPages / sumEditRevs));
      page = Math.max(page, minPage + 1);
      lastDelta = -1;
    } else if (allLate && 1 / allLate) {
      minPage = page;
      page += Math.max(1, Math.ceil(allLate * sumPages / sumEditRevs));
      page = Math.min(page, maxPage - 1);
      lastDelta = 1;
    } else if (!allEarly && !allLate) {
      console.error(`[SE:ERV] snipe hunt: min=${minPage} < page=${page} < max=${maxPage}`);
      break;
    } else {
      console.warn(`[SE:ERV] 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] derailed: min=${minPage} < page=${page} < max=${maxPage}`);
      break;
    }
  }
};

const contentLoaded = new Promise(ok => {
  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 () => {
  let m;
  if (!(m = rxPathnameRev.exec(location.pathname)))
    return;

  const [, postId, revNum] = m;
  
  const tasks = [];
    const spawn = afunc => {
    tasks.push(afunc());
  };

  try {
    spawn(async () => {
      await contentLoaded;
      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 () => {
      await contentLoaded;

      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 = document.querySelector(`
        #content > .subheader + * > .fs-headline1 + *
      `);

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

    // 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"
          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);
    });
  } finally {
    await Promise.all(tasks);
  }
})().catch(e => {
  console.error(`[SE:ERV] `, e);
});

GM.registerMenuCommand('[SE:ERV] Flush all caches', () => {
  Promise.all([
    uuidCache.flush(),
  ]).then(() => {
    alert('Stack Exchange: enhanced revision view – caches flushed');
  }, 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.

Browse other questions tagged .