-
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/
-
-
Save mogsdad/6518632 to your computer and use it in GitHub Desktop.
/** | |
* 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); | |
} | |
} | |
} | |
} | |
} |
/** | |
* 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 && ch != text.length-1) { | |
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 = {}; | |
} | |
} | |
} | |
} | |
else { | |
// Get number of child elements, for elements that can have child elements. | |
try { | |
var numChildren = element.getNumChildren(); | |
} | |
catch (e) { | |
numChildren = 0; | |
} | |
for (var i=0; i<numChildren; i++) { | |
links = links.concat(getAllLinks(element.getChild(i))); | |
} | |
} | |
return links; | |
} | |
/** | |
* 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; | |
} | |
//////// Following are UI components to utilize the utilities above. | |
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); | |
} |
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.
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 :-)
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().
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
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) {