Skip to content

Instantly share code, notes, and snippets.

@mogsdad
Last active April 21, 2024 07:21
Show Gist options
  • Save mogsdad/6518632 to your computer and use it in GitHub Desktop.
Save mogsdad/6518632 to your computer and use it in GitHub Desktop.

Google Apps Script Document Utilities

  • getAllLinks.js

  • getAllLinks(element) - returns array of all UrlLinks in Document

  • findAndReplaceLinks(searchPattern,replacement) - changes all matching links in Document

  • changeCase.js - Document add-in, provides case-change operations in the add-in Menu.

  • onOpen - installs "Change Case" menu

  • _changeCase - worker function to locate selected text and change text case. Case conversion is managed via callback to a function that accepts a string as a parameter and returns the converted string.

  • helper functions for five cases

  • UPPER CASE

  • lower case

  • Title Case

  • Sentence case

  • camelCase

  • Fountain-lite, screenplay formatting - see http://fountain.io/

/**
* Copyright (c) 2014 by Mogsdad (David Bingham)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Google Doc add-on menu will inherit the name of the script project.
*/
function onOpen() {
DocumentApp.getUi().createAddonMenu()
.addItem("UPPER CASE", 'toUpperCase' )
.addItem("lower case", 'toLowerCase' )
.addItem("Title Case", 'toTitleCase' )
.addItem("Sentence case", 'toSentenceCase' )
.addItem("camelCase", 'toCamelCase' )
.addSeparator()
.addItem("Fountain-lite", 'fountainLite')
.addToUi();
}
/**
* Dispatcher functions to provide case-specific
* callback functions to generic _changeCase().
*/
function toUpperCase() {
_changeCase(_toUpperCase);
}
function toLowerCase() {
_changeCase(_toLowerCase);
}
function toSentenceCase() {
_changeCase(_toSentenceCase);
}
function toTitleCase() {
_changeCase(_toTitleCase);
}
function toCamelCase() {
_changeCase(_toCamelCase);
}
/**
* Generic function to implement case change function in Google Docs.
* In case of error, alert window is opened in Google Docs UI with an
* explanation for the user. Exceptions are not caught, but pass through
* to Google Doc UI.
*
* Caveat: formatting is lost, due to operation of replaceText().
*
* @parameter {function} newCase Callback function, reflects an input
* string after case change.
*/
function _changeCase(newCase) {
newCase = newCase || _toUpperCase;
var doc = DocumentApp.getActiveDocument();
var selection = doc.getSelection();
var ui = DocumentApp.getUi();
var report = ""; // Assume success
if (!selection) {
report = "Select text to be modified.";
}
else {
var elements = selection.getSelectedElements();
if (elements.length > 1) {
report = "Select text in one paragraph only.";
}
else {
var element = elements[0].getElement();
//Logger.log( element.getType() );
var startOffset = elements[0].getStartOffset(); // -1 if whole element
var endOffset = elements[0].getEndOffsetInclusive(); // -1 if whole element
var elementText = element.asText().getText(); // All text from element
// Is only part of the element selected?
if (elements[0].isPartial())
var selectedText = elementText.substring(startOffset,endOffset+1);
else
selectedText = elementText;
// Google Doc UI "word selection" (double click)
// selects trailing spaces - trim them
selectedText = selectedText.trim();
//endOffset = startOffset + selectedText.length - 1; // Not necessary w/ replaceText
// Convert case of selected text.
var convertedText = newCase(selectedText);
var regexEscaped = selectedText.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); // http://stackoverflow.com/a/3561711/1677912
element.replaceText(regexEscaped, convertedText);
}
}
if (report !== '') ui.alert( report );
}
/**
* Case change callbacks for customization of generic _changeCase().
* Source credits as noted.
*/
function _toUpperCase(str) {
return str.toUpperCase();
}
function _toLowerCase(str) {
return str.toLowerCase();
}
// http://stackoverflow.com/a/196991/1677912
function _toTitleCase(str)
{
return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();});
}
// http://stackoverflow.com/a/19089667/1677912
function _toSentenceCase (str) {
var rg = /(^\s*\w{1}|\.\s*\w{1})/gi;
return str.toLowerCase().replace(rg, function(toReplace) {
return toReplace.toUpperCase();
});
}
// http://stackoverflow.com/a/2970667/1677912
function _toCamelCase(str) {
return str.replace(/(?:^\w|[A-Z]|\b\w|\s+)/g, function(match, index) {
if (+match === 0) return ""; // or if (/\s+/.test(match)) for white spaces
return index == 0 ? match.toLowerCase() : match.toUpperCase();
});
}
/**
* Scan Google doc, applying fountain syntax rules.
* Caveat: this is a partial implementation.
*
* Supported:
* Character names ahead of speech.
*
* Not supported:
* Everything else. See http://fountain.io/syntax
*/
function fountainLite() {
// Private helper function; find text length of paragraph
function paragraphLen( par ) {
return par.asText().getText().length;
}
var doc = DocumentApp.getActiveDocument();
var paragraphs = doc.getBody().getParagraphs();
var numParagraphs = paragraphs.length;
// Scan document
for (var i=0; i<numParagraphs; i++) {
/*
** Character names are in UPPERCASE.
** Dialogue comes right after Character.
*/
if (paragraphLen(paragraphs[i]) > 0) {
// This paragraph has text. If the preceeding one was blank and the following
// one has text, then this paragraph might be a character name.
if ((i==0 || paragraphLen(paragraphs[i-1]) == 0) && (i < numParagraphs && paragraphLen(paragraphs[i+1]) > 0)) {
var paragraphText = paragraphs[i].asText().getText();
// If no power-user overrides, convert Character to UPPERCASE
if (paragraphText.charAt(0) != '!' && paragraphText.charAt(0) != '@') {
var convertedText = _toUpperCase(paragraphText);
var regexEscaped = paragraphText.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); // http://stackoverflow.com/a/3561711/1677912
paragraphs[i].replaceText(regexEscaped, convertedText);
}
}
}
}
}
@jott-al
Copy link

jott-al commented Sep 5, 2019

getAllLinks line 24: doesnt't capture links at the end of the text element, "if" conditional needs to be amended:
if (url != null && ch != text.length-1) {

@mogsdad
Copy link
Author

mogsdad commented Apr 1, 2020

getAllLinks line 24: doesnt't capture links at the end of the text element, "if" conditional needs to be amended:
if (url != null && ch != text.length-1) {

Thanks - done.

@GMNGeoffrey
Copy link

I'm seeing it not correctly set the endOffsetInclusive for links at the end of an element. Here's my rewrite of getAllLinks, which I think is a bit simpler because it doesn't need as much bookkeeping (at the cost of more nested loops). I think it also fixes what looks like a bug handling different links in concatenated text (current code would assign it all to the first link).

/**
 * Copyright (c) 2014 by Mogsdad (David Bingham)
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * 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) {
  const links = [];
  element = element || DocumentApp.getActiveDocument().getBody();
  
  if (element.getType() === DocumentApp.ElementType.TEXT) {
    const textObj = element.editAsText();
    const text = element.getText();
    const inUrl = false;
    for (let ch=0; ch < text.length; ch++) {
      const url = textObj.getLinkUrl(ch);
      if (url != null) {
        const curUrl = {};
        curUrl.element = element;
        curUrl.url = String( url ); // grab a copy
        curUrl.startOffset = ch;
        while (textObj.getLinkUrl(ch) == url && ch != text.length - 1) {
          ch++;
        }
        curUrl.endOffsetInclusive = ch;
        links.push(curUrl);  // add to links
      }
    }
  }
  else {
    // Get number of child elements, for elements that can have child elements.
    let numChildren = 0
    if (element.getNumChildren != null) {
      numChildren = element.getNumChildren();
    }

    for (let i=0; i<numChildren; i++) {
      links.push(...getAllLinks(element.getChild(i)));
    }
  }

  return links;
}

If you wanted to turn this into a proper GitHub repository, I'd be happy to contribute :-)

@Oroborius
Copy link

How do you use this. I'm trying to load it via App Scripts and it just errors out. I've given it Google Docs API access as well...

Errors on GetBody().

@jott-al
Copy link

jott-al commented Apr 21, 2024

Where did you load it in Apps Script? This is written to run within a Google Doc, access the code editor through Extensions -> Apps Script

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment