Google Docs Add-On: Permission-related error "This App is blocked" - google-apps-script

I made a Google Docs Add-On in the Script Editor. When I try to start the Add-On in Google Docs, it displays this screen after selecting and signing in the Google account:
Not sure if it is related, in the Overview section of App Scripts, the Project OAuth Scopes are:
Connect to an external service
Display and run third-party web content in prompts and sidebars inside Google applications
View and manage documents that this application has been installed in
How can I solve this problem? or how can I debug?
The code is very similar to the official tutorial.
The script (code.gs) is as follow:
/**
* #OnlyCurrentDoc
*
* The above comment directs Apps Script to limit the scope of file
* access for this add-on. It specifies that this add-on will only
* attempt to read or modify the files in which the add-on is used,
* and not all of the user's files. The authorization request message
* presented to users will reflect this limited scope.
*/
/**
* Creates a menu entry in the Google Docs UI when the document is opened.
* This method is only used by the regular add-on, and is never called by
* the mobile add-on version.
*
* #param {object} e The event parameter for a simple onOpen trigger. To
* determine which authorization mode (ScriptApp.AuthMode) the trigger is
* running in, inspect e.authMode.
*/
function onOpen(e) {
DocumentApp.getUi().createAddonMenu()
.addItem('Start', 'showSidebar')
.addToUi();
}
/**
* Runs when the add-on is installed.
* This method is only used by the regular add-on, and is never called by
* the mobile add-on version.
*
* #param {object} e The event parameter for a simple onInstall trigger. To
* determine which authorization mode (ScriptApp.AuthMode) the trigger is
* running in, inspect e.authMode. (In practice, onInstall triggers always
* run in AuthMode.FULL, but onOpen triggers may be AuthMode.LIMITED or
* AuthMode.NONE.)
*/
function onInstall(e) {
onOpen(e);
}
/**
* Opens a sidebar in the document containing the add-on's user interface.
* This method is only used by the regular add-on, and is never called by
* the mobile add-on version.
*/
function showSidebar() {
var ui = HtmlService.createHtmlOutputFromFile('sidebar')
.setTitle('Some Title Here');
DocumentApp.getUi().showSidebar(ui);
}
/**
* Gets the text the user has selected. If there is no selection,
* this function displays an error message.
*
* #return {Array.<string>} The selected text.
*/
function getSelectedText() {
var selection = DocumentApp.getActiveDocument().getSelection();
var text = [];
if (selection) {
var elements = selection.getSelectedElements();
for (var i = 0; i < elements.length; ++i) {
if (elements[i].isPartial()) {
var element = elements[i].getElement().asText();
var startIndex = elements[i].getStartOffset();
var endIndex = elements[i].getEndOffsetInclusive();
text.push(element.getText().substring(startIndex, endIndex + 1));
} else {
var element = elements[i].getElement();
// Only translate elements that can be edited as text; skip images and
// other non-text elements.
if (element.editAsText) {
var elementText = element.asText().getText();
// This check is necessary to exclude images, which return a blank
// text element.
if (elementText) {
text.push(elementText);
}
}
}
}
}
if (!text.length) throw new Error('Please select some text.');
return text;
}
/**
* Obtain the input text and pass to API
*
*/
function getTextAndAnalyse() {
var text = getSelectedText().join("\n");
var query = text.trim();
var apiURL = "https://example.com/api/?q=" + encodeURIComponent(query);
var response = UrlFetchApp.fetch(apiURL, {'muteHttpExceptions': true});
Logger.log(response);
var json = response.getContentText();
var data = JSON.parse(json);
if (data.error != "") throw new Error('Error connecting to API');
return {
result: data.result_value,
examples: data.examples
}
}
/**
* Replaces the text of the current selection with the provided text, or
* inserts text at the current cursor location. (There will always be either
* a selection or a cursor.) If multiple elements are selected, only inserts the
* translated text in the first element that can contain text and removes the
* other elements.
*
* #param {string} newText The text with which to replace the current selection.
*/
function insertText(newText) {
var selection = DocumentApp.getActiveDocument().getSelection();
if (selection) {
var replaced = false;
var elements = selection.getSelectedElements();
if (elements.length === 1 && elements[0].getElement().getType() ===
DocumentApp.ElementType.INLINE_IMAGE) {
throw new Error('Can\'t insert text into an image.');
}
for (var i = 0; i < elements.length; ++i) {
if (elements[i].isPartial()) {
var element = elements[i].getElement().asText();
var startIndex = elements[i].getStartOffset();
var endIndex = elements[i].getEndOffsetInclusive();
element.deleteText(startIndex, endIndex);
if (!replaced) {
element.insertText(startIndex, newText);
replaced = true;
} else {
// This block handles a selection that ends with a partial element. We
// want to copy this partial text to the previous element so we don't
// have a line-break before the last partial.
var parent = element.getParent();
var remainingText = element.getText().substring(endIndex + 1);
parent.getPreviousSibling().asText().appendText(remainingText);
// We cannot remove the last paragraph of a doc. If this is the case,
// just remove the text within the last paragraph instead.
if (parent.getNextSibling()) {
parent.removeFromParent();
} else {
element.removeFromParent();
}
}
} else {
var element = elements[i].getElement();
if (!replaced && element.editAsText) {
// Only translate elements that can be edited as text, removing other
// elements.
element.clear();
element.asText().setText(newText);
replaced = true;
} else {
// We cannot remove the last paragraph of a doc. If this is the case,
// just clear the element.
if (element.getNextSibling()) {
element.removeFromParent();
} else {
element.clear();
}
}
}
}
} else {
var cursor = DocumentApp.getActiveDocument().getCursor();
var surroundingText = cursor.getSurroundingText().getText();
var surroundingTextOffset = cursor.getSurroundingTextOffset();
// If the cursor follows or preceds a non-space character, insert a space
// between the character and the translation. Otherwise, just insert the
// translation.
if (surroundingTextOffset > 0) {
if (surroundingText.charAt(surroundingTextOffset - 1) != ' ') {
newText = ' ' + newText;
}
}
if (surroundingTextOffset < surroundingText.length) {
if (surroundingText.charAt(surroundingTextOffset) != ' ') {
newText += ' ';
}
}
cursor.insertText(newText);
}
}
Here is the contents of appsscript.json:
{
"timeZone": "Asia/Shanghai",
"dependencies": {
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8"
}

You should set the "oauthScopes" explicitly in the manifest file (appsscript.json) if you plan to use the project as an add-on.

Related

Get the first hyperlink and its text value

I hope everyone is in good health health and condition.
Recently, I have been working on Google Docs hyperlinks using app scripts and learning along the way. I was trying to get all hyperlink and edit them and for that I found an amazing code from this post. I have read the code multiple times and now I have a good understanding of how it works.
My confusion
My confusion is the recursive process happening in this code, although I am familiar with the concept of Recursive functions but when I try to modify to code to get only the first hyperlink from the document, I could not understand it how could I achieve that without breaking the recursive function.
Here is the code that I am trying ;
/**
* Get an array of all LinkUrls in the document. The function is
* recursive, and if no element is provided, it will default to
* the active document's Body element.
*
* #param {Element} element The document element to operate on.
* .
* #returns {Array} Array of objects, vis
* {element,
* startOffset,
* endOffsetInclusive,
* url}
*/
function getAllLinks(element) {
var links = [];
element = element || DocumentApp.getActiveDocument().getBody();
if (element.getType() === DocumentApp.ElementType.TEXT) {
var textObj = element.editAsText();
var text = element.getText();
var inUrl = false;
for (var ch=0; ch < text.length; ch++) {
var url = textObj.getLinkUrl(ch);
if (url != null) {
if (!inUrl) {
// We are now!
inUrl = true;
var curUrl = {};
curUrl.element = element;
curUrl.url = String( url ); // grab a copy
curUrl.startOffset = ch;
}
else {
curUrl.endOffsetInclusive = ch;
}
}
else {
if (inUrl) {
// Not any more, we're not.
inUrl = false;
links.push(curUrl); // add to links
curUrl = {};
}
}
}
if (inUrl) {
// in case the link ends on the same char that the element does
links.push(curUrl);
}
}
else {
var numChildren = element.getNumChildren();
for (var i=0; i<numChildren; i++) {
links = links.concat(getAllLinks(element.getChild(i)));
}
}
return links;
}
I tried adding
if (links.length > 0){
return links;
}
but it does not stop the function as it is recursive and it return back to its previous calls and continue running.
Here is the test document along with its script that I am working on.
https://docs.google.com/document/d/1eRvnR2NCdsO94C5nqly4nRXCttNziGhwgR99jElcJ_I/edit?usp=sharing
I hope you will understand what I am trying to convey, Thanks for giving a look at my post. Stay happy :D
I believe your goal as follows.
You want to retrieve the 1st link and the text of link from the shared Document using Google Apps Script.
You want to stop the recursive loop when the 1st element is retrieved.
Modification points:
I tried adding
if (links.length > 0){
return links;
}
but it does not stop the function as it is recursive and it return back to its previous calls and continue running.
About this, unfortunately, I couldn't understand where you put the script in your script. In this case, I think that it is required to stop the loop when links has the value. And also, it is required to also retrieve the text. So, how about modifying as follows? I modified 3 parts in your script.
Modified script:
function getAllLinks(element) {
var links = [];
element = element || DocumentApp.getActiveDocument().getBody();
if (element.getType() === DocumentApp.ElementType.TEXT) {
var textObj = element.editAsText();
var text = element.getText();
var inUrl = false;
for (var ch=0; ch < text.length; ch++) {
if (links.length > 0) break; // <--- Added
var url = textObj.getLinkUrl(ch);
if (url != null) {
if (!inUrl) {
// We are now!
inUrl = true;
var curUrl = {};
curUrl.element = element;
curUrl.url = String( url ); // grab a copy
curUrl.startOffset = ch;
}
else {
curUrl.endOffsetInclusive = ch;
}
}
else {
if (inUrl) {
// Not any more, we're not.
inUrl = false;
curUrl.text = text.slice(curUrl.startOffset, curUrl.endOffsetInclusive + 1); // <--- Added
links.push(curUrl); // add to links
curUrl = {};
}
}
}
if (inUrl) {
// in case the link ends on the same char that the element does
links.push(curUrl);
}
}
else {
var numChildren = element.getNumChildren();
for (var i=0; i<numChildren; i++) {
if (links.length > 0) { // <--- Added or if (links.length > 0) break;
return links;
}
links = links.concat(getAllLinks(element.getChild(i)));
}
}
return links;
}
In this case, I think that if (links.length > 0) {return links;} can be modified to if (links.length > 0) break;.
Note:
By the way, when Google Docs API is used, both the links and the text can be also retrieved by a simple script as follows. When you use this, please enable Google Docs API at Advanced Google services.
function myFunction() {
const doc = DocumentApp.getActiveDocument();
const res = Docs.Documents.get(doc.getId()).body.content.reduce((ar, {paragraph}) => {
if (paragraph && paragraph.elements) {
paragraph.elements.forEach(({textRun}) => {
if (textRun && textRun.textStyle && textRun.textStyle.link) {
ar.push({text: textRun.content, url: textRun.textStyle.link.url});
}
});
}
return ar;
}, []);
console.log(res) // You can retrieve 1st link and test by console.log(res[0]).
}

Google app script - getting all hyperlinks from document [duplicate]

Given a "normal document" in Google Docs/Drive (e.g. paragraphs, lists, tables) which contains external links scattered throughout the content, how do you compile a list of links present using Google Apps Script?
Specifically, I want to update all broken links in the document by searching for oldText in each url and replace it with newText in each url, but not the text.
I don't think the replacing text section of the Dev Documentation is what I need -- do I need to scan every element of the doc? Can I just editAsText and use an html regex? Examples would be appreciated.
This is only mostly painful! Code is available as part of a gist.
Yeah, I can't spell.
getAllLinks
Here's a utility function that scans the document for all LinkUrls, returning them in an array.
/**
* Get an array of all LinkUrls in the document. The function is
* recursive, and if no element is provided, it will default to
* the active document's Body element.
*
* #param {Element} element The document element to operate on.
* .
* #returns {Array} Array of objects, vis
* {element,
* startOffset,
* endOffsetInclusive,
* url}
*/
function getAllLinks(element) {
var links = [];
element = element || DocumentApp.getActiveDocument().getBody();
if (element.getType() === DocumentApp.ElementType.TEXT) {
var textObj = element.editAsText();
var text = element.getText();
var inUrl = false;
for (var ch=0; ch < text.length; ch++) {
var url = textObj.getLinkUrl(ch);
if (url != null) {
if (!inUrl) {
// We are now!
inUrl = true;
var curUrl = {};
curUrl.element = element;
curUrl.url = String( url ); // grab a copy
curUrl.startOffset = ch;
}
else {
curUrl.endOffsetInclusive = ch;
}
}
else {
if (inUrl) {
// Not any more, we're not.
inUrl = false;
links.push(curUrl); // add to links
curUrl = {};
}
}
}
if (inUrl) {
// in case the link ends on the same char that the element does
links.push(curUrl);
}
}
else {
var numChildren = element.getNumChildren();
for (var i=0; i<numChildren; i++) {
links = links.concat(getAllLinks(element.getChild(i)));
}
}
return links;
}
findAndReplaceLinks
This utility builds on getAllLinks to do a find & replace function.
/**
* Replace all or part of UrlLinks in the document.
*
* #param {String} searchPattern the regex pattern to search for
* #param {String} replacement the text to use as replacement
*
* #returns {Number} number of Urls changed
*/
function findAndReplaceLinks(searchPattern,replacement) {
var links = getAllLinks();
var numChanged = 0;
for (var l=0; l<links.length; l++) {
var link = links[l];
if (link.url.match(searchPattern)) {
// This link needs to be changed
var newUrl = link.url.replace(searchPattern,replacement);
link.element.setLinkUrl(link.startOffset, link.endOffsetInclusive, newUrl);
numChanged++
}
}
return numChanged;
}
Demo UI
To demonstrate the use of these utilities, here are a couple of UI extensions:
function onOpen() {
// Add a menu with some items, some separators, and a sub-menu.
DocumentApp.getUi().createMenu('Utils')
.addItem('List Links', 'sidebarLinks')
.addItem('Replace Link Text', 'searchReplaceLinks')
.addToUi();
}
function searchReplaceLinks() {
var ui = DocumentApp.getUi();
var app = UiApp.createApplication()
.setWidth(250)
.setHeight(100)
.setTitle('Change Url text');
var form = app.createFormPanel();
var flow = app.createFlowPanel();
flow.add(app.createLabel("Find: "));
flow.add(app.createTextBox().setName("searchPattern"));
flow.add(app.createLabel("Replace: "));
flow.add(app.createTextBox().setName("replacement"));
var handler = app.createServerHandler('myClickHandler');
flow.add(app.createSubmitButton("Submit").addClickHandler(handler));
form.add(flow);
app.add(form);
ui.showDialog(app);
}
// ClickHandler to close dialog
function myClickHandler(e) {
var app = UiApp.getActiveApplication();
app.close();
return app;
}
function doPost(e) {
var numChanged = findAndReplaceLinks(e.parameter.searchPattern,e.parameter.replacement);
var ui = DocumentApp.getUi();
var app = UiApp.createApplication();
sidebarLinks(); // Update list
var result = DocumentApp.getUi().alert(
'Results',
"Changed "+numChanged+" urls.",
DocumentApp.getUi().ButtonSet.OK);
}
/**
* Shows a custom HTML user interface in a sidebar in the Google Docs editor.
*/
function sidebarLinks() {
var links = getAllLinks();
var sidebar = HtmlService
.createHtmlOutput()
.setTitle('URL Links')
.setWidth(350 /* pixels */);
// Display list of links, url only.
for (var l=0; l<links.length; l++) {
var link = links[l];
sidebar.append('<p>'+link.url);
}
DocumentApp.getUi().showSidebar(sidebar);
}
I offer another, shorter answer for your first question, concerning iterating through all links in a document's body. This instructive code returns a flat array of links in the current document's body, where each link is represented by an object with entries pointing to the text element (text), the paragraph element or list item element in which it's contained (paragraph), the offset index in the text where the link appears (startOffset) and the URL itself (url). Hopefully, you'll find it easy to suit it for your own needs.
It uses the getTextAttributeIndices() method rather than iterating over every character of the text, and is thus expected to perform much more quickly than previously written answers.
EDIT: Since originally posting this answer, I modified the function a couple of times. It now also (1) includes the endOffsetInclusive property for each link (note that it can be null for links that extend to the end of the text element - in this case one can use link.text.length-1 instead); (2) finds links in all sections of the document, not only the body, and (3) includes the section and isFirstPageSection properties to indicate where the link is located; (4) accepts the argument mergeAdjacent, which when set to true, will return only a single link entry for a continuous stretch of text linked to the same URL (which would be considered separate if, for instance, part of the text is styled differently than another part).
For the purpose of including links under all sections, a new utility function, iterateSections(), was introduced.
/**
* Returns a flat array of links which appear in the active document's body.
* Each link is represented by a simple Javascript object with the following
* keys:
* - "section": {ContainerElement} the document section in which the link is
* found.
* - "isFirstPageSection": {Boolean} whether the given section is a first-page
* header/footer section.
* - "paragraph": {ContainerElement} contains a reference to the Paragraph
* or ListItem element in which the link is found.
* - "text": the Text element in which the link is found.
* - "startOffset": {Number} the position (offset) in the link text begins.
* - "endOffsetInclusive": the position of the last character of the link
* text, or null if the link extends to the end of the text element.
* - "url": the URL of the link.
*
* #param {boolean} mergeAdjacent Whether consecutive links which carry
* different attributes (for any reason) should be returned as a single
* entry.
*
* #returns {Array} the aforementioned flat array of links.
*/
function getAllLinks(mergeAdjacent) {
var links = [];
var doc = DocumentApp.getActiveDocument();
iterateSections(doc, function(section, sectionIndex, isFirstPageSection) {
if (!("getParagraphs" in section)) {
// as we're using some undocumented API, adding this to avoid cryptic
// messages upon possible API changes.
throw new Error("An API change has caused this script to stop " +
"working.\n" +
"Section #" + sectionIndex + " of type " +
section.getType() + " has no .getParagraphs() method. " +
"Stopping script.");
}
section.getParagraphs().forEach(function(par) {
// skip empty paragraphs
if (par.getNumChildren() == 0) {
return;
}
// go over all text elements in paragraph / list-item
for (var el=par.getChild(0); el!=null; el=el.getNextSibling()) {
if (el.getType() != DocumentApp.ElementType.TEXT) {
continue;
}
// go over all styling segments in text element
var attributeIndices = el.getTextAttributeIndices();
var lastLink = null;
attributeIndices.forEach(function(startOffset, i, attributeIndices) {
var url = el.getLinkUrl(startOffset);
if (url != null) {
// we hit a link
var endOffsetInclusive = (i+1 < attributeIndices.length?
attributeIndices[i+1]-1 : null);
// check if this and the last found link are continuous
if (mergeAdjacent && lastLink != null && lastLink.url == url &&
lastLink.endOffsetInclusive == startOffset - 1) {
// this and the previous style segment are continuous
lastLink.endOffsetInclusive = endOffsetInclusive;
return;
}
lastLink = {
"section": section,
"isFirstPageSection": isFirstPageSection,
"paragraph": par,
"textEl": el,
"startOffset": startOffset,
"endOffsetInclusive": endOffsetInclusive,
"url": url
};
links.push(lastLink);
}
});
}
});
});
return links;
}
/**
* Calls the given function for each section of the document (body, header,
* etc.). Sections are children of the DocumentElement object.
*
* #param {Document} doc The Document object (such as the one obtained via
* a call to DocumentApp.getActiveDocument()) with the sections to iterate
* over.
* #param {Function} func A callback function which will be called, for each
* section, with the following arguments (in order):
* - {ContainerElement} section - the section element
* - {Number} sectionIndex - the child index of the section, such that
* doc.getBody().getParent().getChild(sectionIndex) == section.
* - {Boolean} isFirstPageSection - whether the section is a first-page
* header/footer section.
*/
function iterateSections(doc, func) {
// get the DocumentElement interface to iterate over all sections
// this bit is undocumented API
var docEl = doc.getBody().getParent();
var regularHeaderSectionIndex = (doc.getHeader() == null? -1 :
docEl.getChildIndex(doc.getHeader()));
var regularFooterSectionIndex = (doc.getFooter() == null? -1 :
docEl.getChildIndex(doc.getFooter()));
for (var i=0; i<docEl.getNumChildren(); ++i) {
var section = docEl.getChild(i);
var sectionType = section.getType();
var uniqueSectionName;
var isFirstPageSection = (
i != regularHeaderSectionIndex &&
i != regularFooterSectionIndex &&
(sectionType == DocumentApp.ElementType.HEADER_SECTION ||
sectionType == DocumentApp.ElementType.FOOTER_SECTION));
func(section, i, isFirstPageSection);
}
}
I was playing around and incorporated #Mogsdad's answer -- here's the really complicated version:
var _ = Underscorejs.load(); // loaded via http://googleappsdeveloper.blogspot.com/2012/11/using-open-source-libraries-in-apps.html, rolled my own
var ui = DocumentApp.getUi();
// #region --------------------- Utilities -----------------------------
var gDocsHelper = (function(P, un) {
// heavily based on answer https://stackoverflow.com/a/18731628/1037948
var updatedLinkText = function(link, offset) {
return function() { return 'Text: ' + link.getText().substring(offset,100) + ((link.getText().length-offset) > 100 ? '...' : ''); }
}
P.updateLink = function updateLink(link, oldText, newText, start, end) {
var oldLink = link.getLinkUrl(start);
if(0 > oldLink.indexOf(oldText)) return false;
var newLink = oldLink.replace(new RegExp(oldText, 'g'), newText);
link.setLinkUrl(start || 0, (end || oldLink.length), newLink);
log(true, "Updating Link: ", oldLink, newLink, start, end, updatedLinkText(link, start) );
return { old: oldLink, "new": newLink, getText: updatedLinkText(link, start) };
};
// moving this reused block out to 'private' fn
var updateLinkResult = function(text, oldText, newText, link, urls, sidebar, updateResult) {
// and may as well update the link while we're here
if(false !== (updateResult = P.updateLink(text, oldText, newText, link.start, link.end))) {
sidebar.append('<li>' + updateResult['old'] + ' → ' + updateResult['new'] + ' at ' + updateResult['getText']() + '</li>');
}
urls.push(link.url); // so multiple links get added to list
};
P.updateLinksMenu = function() {
// https://developers.google.com/apps-script/reference/base/prompt-response
var oldText = ui.prompt('Old link text to replace').getResponseText();
var newText = ui.prompt('New link text to replace with').getResponseText();
log('Replacing: ' + oldText + ', ' + newText);
var sidebar = gDocUiHelper.createSidebar('Update All Links', '<h3>Replacing</h3><p><code>' + oldText + '</code> → <code>' + newText + '</code></p><hr /><ol>');
// current doc available to script
var doc = DocumentApp.getActiveDocument().getBody();//.getActiveSection();
// Search until a link is found
var links = P.findAllElementsFor(doc, function(text) {
var i = -1, n = text.getText().length, link = false, url, urls = [], updateResult;
// note: the following only gets the FIRST link in the text -- while(i < n && !(url = text.getLinkUrl(i++)));
// scan the text element for links
while(++i < n) {
// getLinkUrl will continue to get a link while INSIDE the stupid link, so only do this once
if(url = text.getLinkUrl(i)) {
if(false === link) {
link = { start: i, end: -1, url: url };
// log(true, 'Type: ' + text.getType(), 'Link: ' + url, function() { return 'Text: ' + text.getText().substring(i,100) + ((n-i) > 100 ? '...' : '')});
}
else {
link.end = i; // keep updating the end position until we leave
}
}
// just left the link -- reset link tracking
else if(false !== link) {
// and may as well update the link while we're here
updateLinkResult(text, oldText, newText, link, urls, sidebar);
link = false; // reset "counter"
}
}
// once we've reached the end of the text, must also check to see if the last thing we found was a link
if(false !== link) updateLinkResult(text, oldText, newText, link, urls, sidebar);
return urls;
});
sidebar.append('</ol><p><strong>' + links.length + ' links reviewed</strong></p>');
gDocUiHelper.attachSidebar(sidebar);
log(links);
};
P.findAllElementsFor = function(el, test) {
// generic utility function to recursively find all elements; heavily based on https://stackoverflow.com/a/18731628/1037948
var results = [], searchResult = null, i, result;
// https://developers.google.com/apps-script/reference/document/body#findElement(ElementType)
while (searchResult = el.findElement(DocumentApp.ElementType.TEXT, searchResult)) {
var t = searchResult.getElement().editAsText(); // .asParagraph()
// check to add to list
if(test && (result = test(t))) {
if( _.isArray(result) ) results = results.concat(result); // could be big? http://jsperf.com/self-concatenation/
else results.push(result);
}
}
// recurse children if not plain text item
if(el.getType() !== DocumentApp.ElementType.TEXT) {
i = el.getNumChildren();
var result;
while(--i > 0) {
result = P.findAllElementsFor(el.getChild(i));
if(result && result.length > 0) results = results.concat(result);
}
}
return results;
};
return P;
})({});
// really? it can't handle object properties?
function gDocsUpdateLinksMenu() {
gDocsHelper.updateLinksMenu();
}
gDocUiHelper.addMenu('Zaus', [ ['Update links', 'gDocsUpdateLinksMenu'] ]);
// #endregion --------------------- Utilities -----------------------------
And I'm including the "extra" utility classes for creating menus, sidebars, etc below for completeness:
var log = function() {
// return false;
var args = Array.prototype.slice.call(arguments);
// allowing functions delegates execution so we can save some non-debug cycles if code left in?
if(args[0] === true) Logger.log(_.map(args, function(v) { return _.isFunction(v) ? v() : v; }).join('; '));
else
_.each(args, function(v) {
Logger.log(_.isFunction(v) ? v() : v);
});
}
// #region --------------------- Menu -----------------------------
var gDocUiHelper = (function(P, un) {
P.addMenuToSheet = function addMenu(spreadsheet, title, items) {
var menu = ui.createMenu(title);
// make sure menu items are correct format
_.each(items, function(v,k) {
var err = [];
// provided in format [ [name, fn],... ] instead
if( _.isArray(v) ) {
if ( v.length === 2 ) {
menu.addItem(v[0], v[1]);
}
else {
err.push('Menu item ' + k + ' missing name or function: ' + v.join(';'))
}
}
else {
if( !v.name ) err.push('Menu item ' + k + ' lacks name');
if( !v.functionName ) err.push('Menu item ' + k + ' lacks function');
if(!err.length) menu.addItem(v.name, v.functionName);
}
if(err.length) {
log(err);
ui.alert(err.join('; '));
}
});
menu.addToUi();
};
// list of things to hook into
var initializers = {};
P.addMenu = function(menuTitle, menuItems) {
if(initializers[menuTitle] === un) {
initializers[menuTitle] = [];
}
initializers[menuTitle] = initializers[menuTitle].concat(menuItems);
};
P.createSidebar = function(title, content, options) {
var sidebar = HtmlService
.createHtmlOutput()
.setTitle(title)
.setWidth( (options && options.width) ? width : 350 /* pixels */);
sidebar.append(content);
if(options && options.on) DocumentApp.getUi().showSidebar(sidebar);
// else { sidebar.attach = function() { DocumentApp.getUi().showSidebar(this); }; } // should really attach to prototype...
return sidebar;
};
P.attachSidebar = function(sidebar) {
DocumentApp.getUi().showSidebar(sidebar);
};
P.onOpen = function() {
var spreadsheet = SpreadsheetApp.getActive();
log(initializers);
_.each(initializers, function(v,k) {
P.addMenuToSheet(spreadsheet, k, v);
});
};
return P;
})({});
// #endregion --------------------- Menu -----------------------------
/**
* A special function that runs when the spreadsheet is open, used to add a
* custom menu to the spreadsheet.
*/
function onOpen() {
gDocUiHelper.onOpen();
}
Had some trouble getting Mogsdad's solution to work. Specifically it misses links which end their parent element so there isn't a trailing non-link character to terminate it. I've implemented something which addresses this and returns a standard range element. Sharing here incase someone finds it useful.
function getAllLinks(element) {
var rangeBuilder = DocumentApp.getActiveDocument().newRange();
// Parse the text iteratively to find the start and end indices for each link
if (element.getType() === DocumentApp.ElementType.TEXT) {
var links = [];
var string = element.getText();
var previousUrl = null; // The URL of the previous character
var currentLink = null; // The latest link being built
for (var charIndex = 0; charIndex < string.length; charIndex++) {
var currentUrl = element.getLinkUrl(charIndex);
// New URL means create a new link
if (currentUrl !== null && previousUrl !== currentUrl) {
if (currentLink !== null) links.push(currentLink);
currentLink = {};
currentLink.url = String(currentUrl);
currentLink.startOffset = charIndex;
}
// In a URL means extend the end of the current link
if (currentUrl !== null) {
currentLink.endOffsetInclusive = charIndex;
}
// Not in a URL means close and push the link if ready
if (currentUrl === null) {
if (currentLink !== null) links.push(currentLink);
currentLink = null;
}
// End the loop and go again
previousUrl = currentUrl;
}
// Handle the end case when final character is a link
if (currentLink !== null) links.push(currentLink);
// Convert the links into a range before returning
links.forEach(function(link) {
rangeBuilder.addElement(element, link.startOffset, link.endOffsetInclusive);
});
}
// If not a text element then recursively get links from child elements
else if (element.getNumChildren) {
for (var i = 0; i < element.getNumChildren(); i++) {
rangeBuilder.addRange(getAllLinks(element.getChild(i)));
}
}
return rangeBuilder.build();
}
You are right ... search and replace is not applicable here.
Use setLinkUrl() https://developers.google.com/apps-script/reference/document/container-element#setLinkUrl(String)
Basically you have to iterate through the elements recursively (elements can contain elements) and for each
use getLinkUrl() to get the oldText
if not null , setLinkUrl(newText) .... leaves displayed text unchanged
This Excel macro lists the links from a Word doc. You'd need to copy your data into a Word doc first.
Sub getLinks()
Dim wApp As Word.Application, wDoc As Word.Document
Dim i As Integer, r As Range
Const filePath = "C:\test\test.docx"
Set wApp = CreateObject("Word.Application")
'wApp.Visible = True
Set wDoc = wApp.Documents.Open(filePath)
Set r = Range("A1")
For i = 1 To wDoc.Hyperlinks.Count
r = wDoc.Hyperlinks(i).Address
Set r = r.Offset(1, 0)
Next i
wApp.Quit
Set wDoc = Nothing
Set wApp = Nothing
End Sub
Here's a quick and dirty way to accomplish the same goal with no scripting:
From Google Docs, save the document in RTF format.
In your editor of choice, edit the links in the RTF file (in my case, I wanted to modify all the hyperlinks, so I used Emacs and regexp-replace). Save the file when you're done.
Create a fresh, new Google Doc, and from the menu, select File>Open and open the RTF file. Docs will convert your edited RTF file back into a proper Google Doc, restoring all formatting.
Google Docs' RTF format is pretty complete--I haven't noticed any loss of fidelity in making the round trip, and it has the advantage of fully exposing all the hyperlinks, formatting, and everything else about the document in a form that's easy to edit and to apply regex tools to.

LockService does not prevent concurrent script runs when paired with inputBox [duplicate]

I have a script in Google Sheets, which runs a function when a user clicks on an image. The function modifies content in cells and in order to avoid simultaneous modifications I need to use lock for this function.
I cannot get, why this doesn't work (I still can invoke same function several times from different clients):
function placeBidMP1() {
var lock = LockService.getScriptLock();
lock.waitLock(10000)
placeBid('MP1', 'I21:J25');
lock.releaseLock();
}
placeBid() function is below:
function placeBid(lotName, range) {
var firstPrompt = ui.prompt(lotName + '-lot', 'Please enter your name:', ui.ButtonSet.OK);
var firstPromptSelection = firstPrompt.getSelectedButton();
var userName = firstPrompt.getResponseText();
if (firstPromptSelection == ui.Button.OK) {
do {
var secondPrompt = ui.prompt('Increase by', 'Amount (greater than 0): ', ui.ButtonSet.OK_CANCEL);
var secondPromptSelection = secondPrompt.getSelectedButton();
var increaseAmount = parseInt(secondPrompt.getResponseText());
} while (!(secondPromptSelection == ui.Button.CANCEL) && !(/^[0-9]+$/.test(increaseAmount)) && !(secondPromptSelection == ui.Button.CLOSE));
if (secondPromptSelection != ui.Button.CANCEL & secondPromptSelection != ui.Button.CLOSE) {
var finalPrompt = ui.alert("Price for lot will be increased by " + increaseAmount + " CZK. Are you sure?", ui.ButtonSet.YES_NO);
if (finalPrompt == ui.Button.YES) {
var cell = SpreadsheetApp.getActiveSheet().getRange(range);
var currentCellValue = Number(cell.getValue());
cell.setValue(currentCellValue + Number(increaseAmount));
bidsHistorySheet.appendRow([userName, lotName, cell.getValue()]);
SpreadsheetApp.flush();
showPriceIsIncreased();
} else {showCancelled();}
} else {showCancelled();}
} else {showCancelled();}
}
I have several placeBidMP() functions for different elements on the Sheet and need to lock only separate function from being invoked multiple times.
I've tried as well next way:
if (lock.waitLock(10000)) {
placeBidMP1(...);
}
else {
showCancelled();
}
and in this case, it shows cancellation pop-up straight away.
I still can invoke the same function several times from different clients
The documentation is clear on that part: prompt() method won't persist LockService locks as it suspends script execution awaiting user interaction:
The script resumes after the user dismisses the dialog, but Jdbc connections and LockService locks don't persist across the suspension
and in this case, it shows cancellation pop-up straight away
Nothing strange here as well - if statement evaluates what's inside the condition and coerces the result to Boolean. Take a look at the waitLock() method signature - it returns void, which is a falsy value. You essentially created this: if(false) and this is why showCancelled() fires straight away.
Workaround
You could work around that limitation by emulating what Lock class does. Be aware that this approach is not meant to replace the service, and there are limitations too, specifically:
PropertiesService has quota on reads / writes. A generous one, but you might want to set toSleep interval to higher values to avoid burning through your quota at the expense of precision.
Do not replace the Lock class with this custom implementation - V8 does not put your code in a special context, so the services are directly exposed and can be overridden.
function PropertyLock() {
const toSleep = 10;
let timeoutIn = 0, gotLock = false;
const store = PropertiesService.getScriptProperties();
/**
* #returns {boolean}
*/
this.hasLock = function () {
return gotLock;
};
/**
* #param {number} timeoutInMillis
* #returns {boolean}
*/
this.tryLock = function (timeoutInMillis) {
//emulates "no effect if the lock has already been acquired"
if (this.gotLock) {
return true;
}
timeoutIn === 0 && (timeoutIn = timeoutInMillis);
const stored = store.getProperty("locked");
const isLocked = stored ? JSON.parse(stored) : false;
const canWait = timeoutIn > 0;
if (isLocked && canWait) {
Utilities.sleep(toSleep);
timeoutIn -= toSleep;
return timeoutIn > 0 ?
this.tryLock(timeoutInMillis) :
false;
}
if (!canWait) {
return false;
}
store.setProperty("locked", true);
gotLock = true;
return true;
};
/**
* #returns {void}
*/
this.releaseLock = function () {
store.setProperty("locked", false);
gotLock = false;
};
/**
* #param {number} timeoutInMillis
* #returns {boolean}
*
* #throws {Error}
*/
this.waitLock = function (timeoutInMillis) {
const hasLock = this.tryLock(timeoutInMillis);
if (!hasLock) {
throw new Error("Could not obtain lock");
}
return hasLock;
};
}
Version 2
What follows below is closer to the original and solves one important issue with using PropertiesService as a workaround: if there is an unhandled exception during the execution of the function that acquires the lock, the version above will get the lock stuck indefinitely (can be solved by removing the corresponding script property).
The version below (or as a gist) uses a self-removing time-based trigger set to fire after the current maximum execution time of a script is exceeded (30 minutes) and can be configured to a lower value should one wish to clean up earlier:
var PropertyLock = (() => {
let locked = false;
let timeout = 0;
const store = PropertiesService.getScriptProperties();
const propertyName = "locked";
const triggerName = "PropertyLock.releaseLock";
const toSleep = 10;
const currentGSuiteRuntimeLimit = 30 * 60 * 1e3;
const lock = function () { };
/**
* #returns {boolean}
*/
lock.hasLock = function () {
return locked;
};
/**
* #param {number} timeoutInMillis
* #returns {boolean}
*/
lock.tryLock = function (timeoutInMillis) {
//emulates "no effect if the lock has already been acquired"
if (locked) {
return true;
}
timeout === 0 && (timeout = timeoutInMillis);
const stored = store.getProperty(propertyName);
const isLocked = stored ? JSON.parse(stored) : false;
const canWait = timeout > 0;
if (isLocked && canWait) {
Utilities.sleep(toSleep);
timeout -= toSleep;
return timeout > 0 ?
PropertyLock.tryLock(timeoutInMillis) :
false;
}
if (!canWait) {
return false;
}
try {
store.setProperty(propertyName, true);
ScriptApp.newTrigger(triggerName).timeBased()
.after(currentGSuiteRuntimeLimit).create();
console.log("created trigger");
locked = true;
return locked;
}
catch (error) {
console.error(error);
return false;
}
};
/**
* #returns {void}
*/
lock.releaseLock = function () {
try {
locked = false;
store.setProperty(propertyName, locked);
const trigger = ScriptApp
.getProjectTriggers()
.find(n => n.getHandlerFunction() === triggerName);
console.log({ trigger });
trigger && ScriptApp.deleteTrigger(trigger);
}
catch (error) {
console.error(error);
}
};
/**
* #param {number} timeoutInMillis
* #returns {boolean}
*
* #throws {Error}
*/
lock.waitLock = function (timeoutInMillis) {
const hasLock = PropertyLock.tryLock(timeoutInMillis);
if (!hasLock) {
throw new Error("Could not obtain lock");
}
return hasLock;
};
return lock;
})();
var PropertyLockService = (() => {
const init = function () { };
/**
* #returns {PropertyLock}
*/
init.getScriptLock = function () {
return PropertyLock;
};
return init;
})();
Note that the second version uses static methods and, just as LockService, should not be instantiated (you could go for a class and static methods to enforce this).
References
waitLock() method reference
prompt() method reference
Falsiness concept in JavaScript

Getting past permissions for a file through the API/Apps Script

I'm trying to create a list of files stored in my Google Drive and also a list of their current and previous permissions. Specifically, I want to create a list of files in my Google Drive which at any point in the past have had the 'Anyone with a link can view/edit (etc)' permission set.
I have created a Google Apps Script to do this and I can iterate through all the files OK and I can get files which currently have that permission set, but I can't see a way to get the history of the file's permissions.
I have found and activated the revisions list API: https://developers.google.com/drive/api/v2/reference/revisions/list
This gets revisions but I can't see anywhere that it lists the sharing history of a revision.
Is what I'm attempting to do possible?
It's definitely possible using the Drive Activity API. You can use the Quickstart for Google Apps Script to view all the activity of an item (file or folder) or done by a User. In this case I modified the Quickstart to show the Permissions changes of a given Drive Id.
function listDriveActivity() {
var request = {
itemName: "items/1bFQvSJ8pMdss4jInrrg7bxdae3dKgu-tJqC1A2TktMs", //Id of the file
pageSize: 10};
var response = DriveActivity.Activity.query(request);
var activities = response.activities;
if (activities && activities.length > 0) {
Logger.log('Recent activity:');
for (var i = 0; i < activities.length; i++) {
var activity = activities[i];
var time = getTimeInfo(activity);
var action = getActionInfo(activity.primaryActionDetail);
var actors = activity.actors.map(getActorInfo);
var targets = activity.targets.map(getTargetInfo);
if (action == "permissionChange"){ //Only show permissionChange activity
Logger.log(
'%s: %s, %s, %s', time, truncated(actors), action,
truncated(targets));
}
}
} else {
Logger.log('No activity.');
}
}
/** Returns a string representation of the first elements in a list. */
function truncated(array, opt_limit) {
var limit = opt_limit || 2;
var contents = array.slice(0, limit).join(', ');
var more = array.length > limit ? ', ...' : '';
return '[' + contents + more + ']';
}
/** Returns the name of a set property in an object, or else "unknown". */
function getOneOf(object) {
for (var key in object) {
return key;
}
return 'unknown';
}
/** Returns a time associated with an activity. */
function getTimeInfo(activity) {
if ('timestamp' in activity) {
return activity.timestamp;
}
if ('timeRange' in activity) {
return activity.timeRange.endTime;
}
return 'unknown';
}
/** Returns the type of action. */
function getActionInfo(actionDetail) {
return getOneOf(actionDetail);
}
/** Returns user information, or the type of user if not a known user. */
function getUserInfo(user) {
if ('knownUser' in user) {
var knownUser = user.knownUser;
var isMe = knownUser.isCurrentUser || false;
return isMe ? 'people/me' : knownUser.personName;
}
return getOneOf(user);
}
/** Returns actor information, or the type of actor if not a user. */
function getActorInfo(actor) {
if ('user' in actor) {
return getUserInfo(actor.user)
}
return getOneOf(actor);
}
/** Returns the type of a target and an associated title. */
function getTargetInfo(target) {
if ('driveItem' in target) {
var title = target.driveItem.title || 'unknown';
return 'driveItem:"' + title + '"';
}
if ('drive' in target) {
var title = target.drive.title || 'unknown';
return 'drive:"' + title + '"';
}
if ('fileComment' in target) {
var parent = target.fileComment.parent || {};
var title = parent.title || 'unknown';
return 'fileComment:"' + title + '"';
}
return getOneOf(target) + ':unknown';
}
Remember to enable the Drive Activity API in Resources > Advanced Google Services
In my example this returns the logs:
You can also look deeper into the Permissions by using the permissionChange Parameters in the query.
If you have a business/enterprise/edu account the admin audit logs will tell you this for 6 months of data. Or it will at least tell you when a permission was changed from x to y.
Can't think of a method for personal.

Get All Links in a Document

Given a "normal document" in Google Docs/Drive (e.g. paragraphs, lists, tables) which contains external links scattered throughout the content, how do you compile a list of links present using Google Apps Script?
Specifically, I want to update all broken links in the document by searching for oldText in each url and replace it with newText in each url, but not the text.
I don't think the replacing text section of the Dev Documentation is what I need -- do I need to scan every element of the doc? Can I just editAsText and use an html regex? Examples would be appreciated.
This is only mostly painful! Code is available as part of a gist.
Yeah, I can't spell.
getAllLinks
Here's a utility function that scans the document for all LinkUrls, returning them in an array.
/**
* Get an array of all LinkUrls in the document. The function is
* recursive, and if no element is provided, it will default to
* the active document's Body element.
*
* #param {Element} element The document element to operate on.
* .
* #returns {Array} Array of objects, vis
* {element,
* startOffset,
* endOffsetInclusive,
* url}
*/
function getAllLinks(element) {
var links = [];
element = element || DocumentApp.getActiveDocument().getBody();
if (element.getType() === DocumentApp.ElementType.TEXT) {
var textObj = element.editAsText();
var text = element.getText();
var inUrl = false;
for (var ch=0; ch < text.length; ch++) {
var url = textObj.getLinkUrl(ch);
if (url != null) {
if (!inUrl) {
// We are now!
inUrl = true;
var curUrl = {};
curUrl.element = element;
curUrl.url = String( url ); // grab a copy
curUrl.startOffset = ch;
}
else {
curUrl.endOffsetInclusive = ch;
}
}
else {
if (inUrl) {
// Not any more, we're not.
inUrl = false;
links.push(curUrl); // add to links
curUrl = {};
}
}
}
if (inUrl) {
// in case the link ends on the same char that the element does
links.push(curUrl);
}
}
else {
var numChildren = element.getNumChildren();
for (var i=0; i<numChildren; i++) {
links = links.concat(getAllLinks(element.getChild(i)));
}
}
return links;
}
findAndReplaceLinks
This utility builds on getAllLinks to do a find & replace function.
/**
* Replace all or part of UrlLinks in the document.
*
* #param {String} searchPattern the regex pattern to search for
* #param {String} replacement the text to use as replacement
*
* #returns {Number} number of Urls changed
*/
function findAndReplaceLinks(searchPattern,replacement) {
var links = getAllLinks();
var numChanged = 0;
for (var l=0; l<links.length; l++) {
var link = links[l];
if (link.url.match(searchPattern)) {
// This link needs to be changed
var newUrl = link.url.replace(searchPattern,replacement);
link.element.setLinkUrl(link.startOffset, link.endOffsetInclusive, newUrl);
numChanged++
}
}
return numChanged;
}
Demo UI
To demonstrate the use of these utilities, here are a couple of UI extensions:
function onOpen() {
// Add a menu with some items, some separators, and a sub-menu.
DocumentApp.getUi().createMenu('Utils')
.addItem('List Links', 'sidebarLinks')
.addItem('Replace Link Text', 'searchReplaceLinks')
.addToUi();
}
function searchReplaceLinks() {
var ui = DocumentApp.getUi();
var app = UiApp.createApplication()
.setWidth(250)
.setHeight(100)
.setTitle('Change Url text');
var form = app.createFormPanel();
var flow = app.createFlowPanel();
flow.add(app.createLabel("Find: "));
flow.add(app.createTextBox().setName("searchPattern"));
flow.add(app.createLabel("Replace: "));
flow.add(app.createTextBox().setName("replacement"));
var handler = app.createServerHandler('myClickHandler');
flow.add(app.createSubmitButton("Submit").addClickHandler(handler));
form.add(flow);
app.add(form);
ui.showDialog(app);
}
// ClickHandler to close dialog
function myClickHandler(e) {
var app = UiApp.getActiveApplication();
app.close();
return app;
}
function doPost(e) {
var numChanged = findAndReplaceLinks(e.parameter.searchPattern,e.parameter.replacement);
var ui = DocumentApp.getUi();
var app = UiApp.createApplication();
sidebarLinks(); // Update list
var result = DocumentApp.getUi().alert(
'Results',
"Changed "+numChanged+" urls.",
DocumentApp.getUi().ButtonSet.OK);
}
/**
* Shows a custom HTML user interface in a sidebar in the Google Docs editor.
*/
function sidebarLinks() {
var links = getAllLinks();
var sidebar = HtmlService
.createHtmlOutput()
.setTitle('URL Links')
.setWidth(350 /* pixels */);
// Display list of links, url only.
for (var l=0; l<links.length; l++) {
var link = links[l];
sidebar.append('<p>'+link.url);
}
DocumentApp.getUi().showSidebar(sidebar);
}
I offer another, shorter answer for your first question, concerning iterating through all links in a document's body. This instructive code returns a flat array of links in the current document's body, where each link is represented by an object with entries pointing to the text element (text), the paragraph element or list item element in which it's contained (paragraph), the offset index in the text where the link appears (startOffset) and the URL itself (url). Hopefully, you'll find it easy to suit it for your own needs.
It uses the getTextAttributeIndices() method rather than iterating over every character of the text, and is thus expected to perform much more quickly than previously written answers.
EDIT: Since originally posting this answer, I modified the function a couple of times. It now also (1) includes the endOffsetInclusive property for each link (note that it can be null for links that extend to the end of the text element - in this case one can use link.text.length-1 instead); (2) finds links in all sections of the document, not only the body, and (3) includes the section and isFirstPageSection properties to indicate where the link is located; (4) accepts the argument mergeAdjacent, which when set to true, will return only a single link entry for a continuous stretch of text linked to the same URL (which would be considered separate if, for instance, part of the text is styled differently than another part).
For the purpose of including links under all sections, a new utility function, iterateSections(), was introduced.
/**
* Returns a flat array of links which appear in the active document's body.
* Each link is represented by a simple Javascript object with the following
* keys:
* - "section": {ContainerElement} the document section in which the link is
* found.
* - "isFirstPageSection": {Boolean} whether the given section is a first-page
* header/footer section.
* - "paragraph": {ContainerElement} contains a reference to the Paragraph
* or ListItem element in which the link is found.
* - "text": the Text element in which the link is found.
* - "startOffset": {Number} the position (offset) in the link text begins.
* - "endOffsetInclusive": the position of the last character of the link
* text, or null if the link extends to the end of the text element.
* - "url": the URL of the link.
*
* #param {boolean} mergeAdjacent Whether consecutive links which carry
* different attributes (for any reason) should be returned as a single
* entry.
*
* #returns {Array} the aforementioned flat array of links.
*/
function getAllLinks(mergeAdjacent) {
var links = [];
var doc = DocumentApp.getActiveDocument();
iterateSections(doc, function(section, sectionIndex, isFirstPageSection) {
if (!("getParagraphs" in section)) {
// as we're using some undocumented API, adding this to avoid cryptic
// messages upon possible API changes.
throw new Error("An API change has caused this script to stop " +
"working.\n" +
"Section #" + sectionIndex + " of type " +
section.getType() + " has no .getParagraphs() method. " +
"Stopping script.");
}
section.getParagraphs().forEach(function(par) {
// skip empty paragraphs
if (par.getNumChildren() == 0) {
return;
}
// go over all text elements in paragraph / list-item
for (var el=par.getChild(0); el!=null; el=el.getNextSibling()) {
if (el.getType() != DocumentApp.ElementType.TEXT) {
continue;
}
// go over all styling segments in text element
var attributeIndices = el.getTextAttributeIndices();
var lastLink = null;
attributeIndices.forEach(function(startOffset, i, attributeIndices) {
var url = el.getLinkUrl(startOffset);
if (url != null) {
// we hit a link
var endOffsetInclusive = (i+1 < attributeIndices.length?
attributeIndices[i+1]-1 : null);
// check if this and the last found link are continuous
if (mergeAdjacent && lastLink != null && lastLink.url == url &&
lastLink.endOffsetInclusive == startOffset - 1) {
// this and the previous style segment are continuous
lastLink.endOffsetInclusive = endOffsetInclusive;
return;
}
lastLink = {
"section": section,
"isFirstPageSection": isFirstPageSection,
"paragraph": par,
"textEl": el,
"startOffset": startOffset,
"endOffsetInclusive": endOffsetInclusive,
"url": url
};
links.push(lastLink);
}
});
}
});
});
return links;
}
/**
* Calls the given function for each section of the document (body, header,
* etc.). Sections are children of the DocumentElement object.
*
* #param {Document} doc The Document object (such as the one obtained via
* a call to DocumentApp.getActiveDocument()) with the sections to iterate
* over.
* #param {Function} func A callback function which will be called, for each
* section, with the following arguments (in order):
* - {ContainerElement} section - the section element
* - {Number} sectionIndex - the child index of the section, such that
* doc.getBody().getParent().getChild(sectionIndex) == section.
* - {Boolean} isFirstPageSection - whether the section is a first-page
* header/footer section.
*/
function iterateSections(doc, func) {
// get the DocumentElement interface to iterate over all sections
// this bit is undocumented API
var docEl = doc.getBody().getParent();
var regularHeaderSectionIndex = (doc.getHeader() == null? -1 :
docEl.getChildIndex(doc.getHeader()));
var regularFooterSectionIndex = (doc.getFooter() == null? -1 :
docEl.getChildIndex(doc.getFooter()));
for (var i=0; i<docEl.getNumChildren(); ++i) {
var section = docEl.getChild(i);
var sectionType = section.getType();
var uniqueSectionName;
var isFirstPageSection = (
i != regularHeaderSectionIndex &&
i != regularFooterSectionIndex &&
(sectionType == DocumentApp.ElementType.HEADER_SECTION ||
sectionType == DocumentApp.ElementType.FOOTER_SECTION));
func(section, i, isFirstPageSection);
}
}
I was playing around and incorporated #Mogsdad's answer -- here's the really complicated version:
var _ = Underscorejs.load(); // loaded via http://googleappsdeveloper.blogspot.com/2012/11/using-open-source-libraries-in-apps.html, rolled my own
var ui = DocumentApp.getUi();
// #region --------------------- Utilities -----------------------------
var gDocsHelper = (function(P, un) {
// heavily based on answer https://stackoverflow.com/a/18731628/1037948
var updatedLinkText = function(link, offset) {
return function() { return 'Text: ' + link.getText().substring(offset,100) + ((link.getText().length-offset) > 100 ? '...' : ''); }
}
P.updateLink = function updateLink(link, oldText, newText, start, end) {
var oldLink = link.getLinkUrl(start);
if(0 > oldLink.indexOf(oldText)) return false;
var newLink = oldLink.replace(new RegExp(oldText, 'g'), newText);
link.setLinkUrl(start || 0, (end || oldLink.length), newLink);
log(true, "Updating Link: ", oldLink, newLink, start, end, updatedLinkText(link, start) );
return { old: oldLink, "new": newLink, getText: updatedLinkText(link, start) };
};
// moving this reused block out to 'private' fn
var updateLinkResult = function(text, oldText, newText, link, urls, sidebar, updateResult) {
// and may as well update the link while we're here
if(false !== (updateResult = P.updateLink(text, oldText, newText, link.start, link.end))) {
sidebar.append('<li>' + updateResult['old'] + ' → ' + updateResult['new'] + ' at ' + updateResult['getText']() + '</li>');
}
urls.push(link.url); // so multiple links get added to list
};
P.updateLinksMenu = function() {
// https://developers.google.com/apps-script/reference/base/prompt-response
var oldText = ui.prompt('Old link text to replace').getResponseText();
var newText = ui.prompt('New link text to replace with').getResponseText();
log('Replacing: ' + oldText + ', ' + newText);
var sidebar = gDocUiHelper.createSidebar('Update All Links', '<h3>Replacing</h3><p><code>' + oldText + '</code> → <code>' + newText + '</code></p><hr /><ol>');
// current doc available to script
var doc = DocumentApp.getActiveDocument().getBody();//.getActiveSection();
// Search until a link is found
var links = P.findAllElementsFor(doc, function(text) {
var i = -1, n = text.getText().length, link = false, url, urls = [], updateResult;
// note: the following only gets the FIRST link in the text -- while(i < n && !(url = text.getLinkUrl(i++)));
// scan the text element for links
while(++i < n) {
// getLinkUrl will continue to get a link while INSIDE the stupid link, so only do this once
if(url = text.getLinkUrl(i)) {
if(false === link) {
link = { start: i, end: -1, url: url };
// log(true, 'Type: ' + text.getType(), 'Link: ' + url, function() { return 'Text: ' + text.getText().substring(i,100) + ((n-i) > 100 ? '...' : '')});
}
else {
link.end = i; // keep updating the end position until we leave
}
}
// just left the link -- reset link tracking
else if(false !== link) {
// and may as well update the link while we're here
updateLinkResult(text, oldText, newText, link, urls, sidebar);
link = false; // reset "counter"
}
}
// once we've reached the end of the text, must also check to see if the last thing we found was a link
if(false !== link) updateLinkResult(text, oldText, newText, link, urls, sidebar);
return urls;
});
sidebar.append('</ol><p><strong>' + links.length + ' links reviewed</strong></p>');
gDocUiHelper.attachSidebar(sidebar);
log(links);
};
P.findAllElementsFor = function(el, test) {
// generic utility function to recursively find all elements; heavily based on https://stackoverflow.com/a/18731628/1037948
var results = [], searchResult = null, i, result;
// https://developers.google.com/apps-script/reference/document/body#findElement(ElementType)
while (searchResult = el.findElement(DocumentApp.ElementType.TEXT, searchResult)) {
var t = searchResult.getElement().editAsText(); // .asParagraph()
// check to add to list
if(test && (result = test(t))) {
if( _.isArray(result) ) results = results.concat(result); // could be big? http://jsperf.com/self-concatenation/
else results.push(result);
}
}
// recurse children if not plain text item
if(el.getType() !== DocumentApp.ElementType.TEXT) {
i = el.getNumChildren();
var result;
while(--i > 0) {
result = P.findAllElementsFor(el.getChild(i));
if(result && result.length > 0) results = results.concat(result);
}
}
return results;
};
return P;
})({});
// really? it can't handle object properties?
function gDocsUpdateLinksMenu() {
gDocsHelper.updateLinksMenu();
}
gDocUiHelper.addMenu('Zaus', [ ['Update links', 'gDocsUpdateLinksMenu'] ]);
// #endregion --------------------- Utilities -----------------------------
And I'm including the "extra" utility classes for creating menus, sidebars, etc below for completeness:
var log = function() {
// return false;
var args = Array.prototype.slice.call(arguments);
// allowing functions delegates execution so we can save some non-debug cycles if code left in?
if(args[0] === true) Logger.log(_.map(args, function(v) { return _.isFunction(v) ? v() : v; }).join('; '));
else
_.each(args, function(v) {
Logger.log(_.isFunction(v) ? v() : v);
});
}
// #region --------------------- Menu -----------------------------
var gDocUiHelper = (function(P, un) {
P.addMenuToSheet = function addMenu(spreadsheet, title, items) {
var menu = ui.createMenu(title);
// make sure menu items are correct format
_.each(items, function(v,k) {
var err = [];
// provided in format [ [name, fn],... ] instead
if( _.isArray(v) ) {
if ( v.length === 2 ) {
menu.addItem(v[0], v[1]);
}
else {
err.push('Menu item ' + k + ' missing name or function: ' + v.join(';'))
}
}
else {
if( !v.name ) err.push('Menu item ' + k + ' lacks name');
if( !v.functionName ) err.push('Menu item ' + k + ' lacks function');
if(!err.length) menu.addItem(v.name, v.functionName);
}
if(err.length) {
log(err);
ui.alert(err.join('; '));
}
});
menu.addToUi();
};
// list of things to hook into
var initializers = {};
P.addMenu = function(menuTitle, menuItems) {
if(initializers[menuTitle] === un) {
initializers[menuTitle] = [];
}
initializers[menuTitle] = initializers[menuTitle].concat(menuItems);
};
P.createSidebar = function(title, content, options) {
var sidebar = HtmlService
.createHtmlOutput()
.setTitle(title)
.setWidth( (options && options.width) ? width : 350 /* pixels */);
sidebar.append(content);
if(options && options.on) DocumentApp.getUi().showSidebar(sidebar);
// else { sidebar.attach = function() { DocumentApp.getUi().showSidebar(this); }; } // should really attach to prototype...
return sidebar;
};
P.attachSidebar = function(sidebar) {
DocumentApp.getUi().showSidebar(sidebar);
};
P.onOpen = function() {
var spreadsheet = SpreadsheetApp.getActive();
log(initializers);
_.each(initializers, function(v,k) {
P.addMenuToSheet(spreadsheet, k, v);
});
};
return P;
})({});
// #endregion --------------------- Menu -----------------------------
/**
* A special function that runs when the spreadsheet is open, used to add a
* custom menu to the spreadsheet.
*/
function onOpen() {
gDocUiHelper.onOpen();
}
Had some trouble getting Mogsdad's solution to work. Specifically it misses links which end their parent element so there isn't a trailing non-link character to terminate it. I've implemented something which addresses this and returns a standard range element. Sharing here incase someone finds it useful.
function getAllLinks(element) {
var rangeBuilder = DocumentApp.getActiveDocument().newRange();
// Parse the text iteratively to find the start and end indices for each link
if (element.getType() === DocumentApp.ElementType.TEXT) {
var links = [];
var string = element.getText();
var previousUrl = null; // The URL of the previous character
var currentLink = null; // The latest link being built
for (var charIndex = 0; charIndex < string.length; charIndex++) {
var currentUrl = element.getLinkUrl(charIndex);
// New URL means create a new link
if (currentUrl !== null && previousUrl !== currentUrl) {
if (currentLink !== null) links.push(currentLink);
currentLink = {};
currentLink.url = String(currentUrl);
currentLink.startOffset = charIndex;
}
// In a URL means extend the end of the current link
if (currentUrl !== null) {
currentLink.endOffsetInclusive = charIndex;
}
// Not in a URL means close and push the link if ready
if (currentUrl === null) {
if (currentLink !== null) links.push(currentLink);
currentLink = null;
}
// End the loop and go again
previousUrl = currentUrl;
}
// Handle the end case when final character is a link
if (currentLink !== null) links.push(currentLink);
// Convert the links into a range before returning
links.forEach(function(link) {
rangeBuilder.addElement(element, link.startOffset, link.endOffsetInclusive);
});
}
// If not a text element then recursively get links from child elements
else if (element.getNumChildren) {
for (var i = 0; i < element.getNumChildren(); i++) {
rangeBuilder.addRange(getAllLinks(element.getChild(i)));
}
}
return rangeBuilder.build();
}
You are right ... search and replace is not applicable here.
Use setLinkUrl() https://developers.google.com/apps-script/reference/document/container-element#setLinkUrl(String)
Basically you have to iterate through the elements recursively (elements can contain elements) and for each
use getLinkUrl() to get the oldText
if not null , setLinkUrl(newText) .... leaves displayed text unchanged
This Excel macro lists the links from a Word doc. You'd need to copy your data into a Word doc first.
Sub getLinks()
Dim wApp As Word.Application, wDoc As Word.Document
Dim i As Integer, r As Range
Const filePath = "C:\test\test.docx"
Set wApp = CreateObject("Word.Application")
'wApp.Visible = True
Set wDoc = wApp.Documents.Open(filePath)
Set r = Range("A1")
For i = 1 To wDoc.Hyperlinks.Count
r = wDoc.Hyperlinks(i).Address
Set r = r.Offset(1, 0)
Next i
wApp.Quit
Set wDoc = Nothing
Set wApp = Nothing
End Sub
Here's a quick and dirty way to accomplish the same goal with no scripting:
From Google Docs, save the document in RTF format.
In your editor of choice, edit the links in the RTF file (in my case, I wanted to modify all the hyperlinks, so I used Emacs and regexp-replace). Save the file when you're done.
Create a fresh, new Google Doc, and from the menu, select File>Open and open the RTF file. Docs will convert your edited RTF file back into a proper Google Doc, restoring all formatting.
Google Docs' RTF format is pretty complete--I haven't noticed any loss of fidelity in making the round trip, and it has the advantage of fully exposing all the hyperlinks, formatting, and everything else about the document in a form that's easy to edit and to apply regex tools to.