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