Google-docs – Google Docs – Automatic Links/Macros to Section Numbers

google docsgoogle-docs-addongoogle-docs-template

I'm not sure the proper terms for what I'm trying to do, but essentially, consider if I have the following headers in my document:

1. Section
  a. Sub-Section
  b. Sub-Section
  c. Sub-Section
    1. Sub-sub-section
2. Section
  a. Sub-Section
3. Section
  a. Sub-Section

Now, what one could refer to a section by combining all of the section numbers/letters, so "1.c.1" would point to a single section.

Is there a feature in Google Docs, or an easy to use macro/add-on which would allow me to create a link to section "1.c.1" which would automatically update if that sections number was changed?

When I say change, I mean something like if I added a section between 1 and 2, that new one would be 2, 2 would be 3, 3 would be 4.

I could easily create a normal page link, but I'd really prefer not to have to seek out every single reference to every single section have to manually update those references. If it included the header text of the section along with the complete number, that would be a plus (like "1.c.1 Sub-Sub-Section").

Best Answer

I couldn't find an already built solution, so I decided to build my own.

Turns out, the API doesn't actually let you get direct header URLs (https://issuetracker.google.com/issues/36761940). Funny enough, the ability was requested 4 years ago and just today was sent to the Google team to investigate.

It turns out there is a workaround to this as long as the document has a Table of Contents that is up-to-date. That was a workaround I could (begrudgingly) accept. Here is the code I wrote.

When run, it will find all links which reference a heading (starts with #heading) and will update the text of the link to match the text found in the Table of Contents (which if up-to-date, matches the heading text itself).

function onOpen() {
  DocumentApp.getUi().createAddonMenu()
    .addItem("Update Heading Links", 'updateLinks')
    .addToUi();
}

function getNextElement(current) {
  let next = (current.getType() !== DocumentApp.ElementType.TABLE_OF_CONTENTS && current.getNumChildren && current.getNumChildren() > 0 && current.getChild(0))
    || current.getNextSibling();

  while (current.getParent() && !next) {
    const parent = current.getParent();
    next = parent.getNextSibling();
    current = parent;
  }

  return next && next.getType() === DocumentApp.ElementType.TABLE_OF_CONTENTS ? getNextElement(next) : next;
}

function findNextLink(element, start) {
  if (element.getType() === DocumentApp.ElementType.TEXT) {
    const text = element.getText();
    const textObj = element.editAsText();
    let inUrl = false;
    let linkText = '';
    let currentUrl, urlStartPosition, position, len;
    let links = [];

    for (position = start || 0, len = text.length; position < len; position++) {
      var url = textObj.getLinkUrl(position);

      if (url) {
        if (!inUrl) {
          inUrl = true;
          currentUrl = url;
          urlStartPosition = position;
        } else if (currentUrl !== url) {
          break;
        }

        linkText += text[position];
      } else if (inUrl) {
        links.push({
          element,
          url: currentUrl,
          text: linkText,
          position: urlStartPosition
        });

        inUrl = false;
        linkText = '';
      }
    }

    if (inUrl) {
      links.push({
        element,
        url: currentUrl,
        text: linkText,
        position: urlStartPosition
      });
    }

    if (links.length) {
      return {
        element: getNextElement(element),
        offset: 0,
        links
      };
    }
  }

  var next = getNextElement(element);

  if (next) {
    return findNextLink(next, 0);
  }

  return null;
}

function getToc() {
  const doc = DocumentApp.getActiveDocument();

  for (let i = 0, len = doc.getNumChildren(); i < len; i++) {
    const element = doc.getChild(i);

    if (element.getType() == DocumentApp.ElementType.TABLE_OF_CONTENTS) {
      return element.asTableOfContents();
    }
  }
}

function getAllHeadings(toc) {
  const headings = {};

  for (let i = 0, len = toc.getNumChildren(); i < len; i++) {
    if(!toc.getChild(i).getNumChildren()) continue;

    const itemToc = toc.getChild(i).getChild(0).editAsText();
    let text = itemToc.getText();
    let linkUrl;

    for (let j = 0, textLen = text.length; j < textLen; j++) {
      linkUrl = itemToc.getLinkUrl(j);

      if (linkUrl) break;
    }

    headings[linkUrl] = text.replace(/\s*\t.*$/g, '').trim();
  }

  return headings;
}

function updateLinks() {
  const toc = getToc();

  if (!toc) {
    DocumentApp.getUi().alert("Due to a limitation in the Google API, you must have a Table of Contents");
    return;
  }

  const headings = getAllHeadings(toc);

  let current = DocumentApp.getActiveDocument().getBody();
  let offset = 0;
  let count = 0;
  let errors = 0;
  let skipped = 0;
  let nonHeader = 0;
  let noChange = 0;
  let links = [];
  let result;

  while (current && (result = findNextLink(current, offset))) {
    current = result.element;
    offset = result.offset;
    const { links } = result;

    let shift = 0;
    if (links.every(({ url, position, text, element }, index) => {
      if (!/^#heading/.test(url)) {
        nonHeader++;
        return;
      } else if (!headings[url]) {
        DocumentApp.getUi().alert("Unable to find header URL with current text: " + text 
          + "\n\nPlease manually refresh the table of contents and try again. If the problem persists, you may need to find and update that link.\n\n" 
          + "The paragraph with the link has the following text:\n\n" 
          + element.getText());
        errors++;
        skipped += links.length - index - 1;
        return;
      } if (headings[url] === text) {
        noChange++;
      } else {
        element.setText(element.getText().substr(0, position + shift) + headings[url] + element.getText().substr(position + shift + text.length));
        shift += headings[url].length - text.length;
      }

      return true;
    })) {
      // must do all text edits before setting links
      shift = 0;
      links.forEach(({ url, position, text, element }) => {
        if (text !== headings[url]) {
          element.setLinkUrl(position + shift, position + shift + headings[url].length - 1, url);
          shift += headings[url].length - text.length;
          count++;
        }
      });
    }
  }

  DocumentApp.getUi().alert(
    "Links updated. Results:\n\n"
        + "Updated: " + count + "\n"
        + "Already Up-To-Date: " + noChange + "\n"
        + "Non-Header Links: " + nonHeader + "\n"
        + "Errors: " + errors + "\n"
        + "Skipped Because Of Errors In Same Paragraph: " + skipped
  );
}