<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[OpinionatedAppsScript]]></title><description><![CDATA[Obsidian digital garden]]></description><link>http://github.com/dylang/node-rss</link><image><url>site-lib/media/favicon.png</url><title>OpinionatedAppsScript</title><link/></image><generator>Webpage HTML Export plugin for Obsidian</generator><lastBuildDate>Tue, 19 May 2026 11:11:08 GMT</lastBuildDate><atom:link href="site-lib/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Tue, 19 May 2026 11:10:43 GMT</pubDate><ttl>60</ttl><dc:creator/><item><title><![CDATA[Export Sheet Structure To JSON]]></title><description><![CDATA[See the code <a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/u/0/home/projects/18VEfiimnOqZwZ_Sj3F6k4h6tm1JgyMe2cFDkwD73PiH3Z7tbS-oWNde7/edit" target="_self">https://script.google.com/u/0/home/projects/18VEfiimnOqZwZ_Sj3F6k4h6tm1JgyMe2cFDkwD73PiH3Z7tbS-oWNde7/edit</a>Or use the webapp version (a lot easier) which also has a tab to DIFF a previous structure so you can see if a sheet or header has been renamed or removed etc.<br><img alt="Pasted image 20260519120625.png" src="examples/media/pasted-image-20260519120625.png" target="_self" style="width: 335px; max-width: 100%;"><br>
<a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/a/macros/york.ac.uk/s/AKfycbzzij5R6J_YWLn1cfjlUItQEUlbZgpb3duGW-OoNdZwqDc5dgwAiLCvgFJ56pb7U4wqJA/exec" target="_self">https://script.google.com/a/macros/york.ac.uk/s/AKfycbzzij5R6J_YWLn1cfjlUItQEUlbZgpb3duGW-OoNdZwqDc5dgwAiLCvgFJ56pb7U4wqJA/exec</a>]]></description><link>examples/export-sheet-structure-to-json.html</link><guid isPermaLink="false">Examples/Export Sheet Structure To JSON.md</guid><pubDate>Tue, 19 May 2026 11:07:15 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Pasted image 20260519120625]]></title><description><![CDATA[<img src="examples/media/pasted-image-20260519120625.png" target="_self">]]></description><link>examples/media/pasted-image-20260519120625.html</link><guid isPermaLink="false">Examples/media/Pasted image 20260519120625.png</guid><pubDate>Tue, 19 May 2026 11:06:25 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Authenticated WebApp]]></title><description><![CDATA[Simple authentication so your webapp can be shared with external people. Uses a Users sheet.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1NZIJ26aYSOv7vO8yuPi25wSQ2I2-EvChP_vikRNeKf8/edit?gid=0#gid=0" target="_self">https://docs.google.com/spreadsheets/d/1NZIJ26aYSOv7vO8yuPi25wSQ2I2-EvChP_vikRNeKf8/edit?gid=0#gid=0</a>]]></description><link>examples/authenticated-webapp.html</link><guid isPermaLink="false">Examples/Authenticated WebApp.md</guid><pubDate>Wed, 29 Apr 2026 08:43:55 GMT</pubDate></item><item><title><![CDATA[OCR PDFs with Google Drive API]]></title><description><![CDATA[Requires Drive API v2 adding to your project
function getTextFromPdf(fileId) { try { var file = DriveApp.getFileById(fileId); // 1. Prepare resource // CHANGE: We removed 'mimeType: MimeType.GOOGLE_DOCS' var resource = { title: file.getName().replace(/\.(pdf|jpg|jpeg|png)$/i, "") }; // 2. Insert with OCR (Drive API v2) var insertOptions = { ocr: true, ocrLanguage: "en" }; // 3. Perform Insert var blob = file.getBlob(); // This will now create a NEW Google Doc containing the text var tempFile = Drive.Files.insert(resource, blob, insertOptions); // 4. Get Text var tempDoc = DocumentApp.openById(tempFile.id); var text = tempDoc.getBody().getText(); // 5. Cleanup Drive.Files.remove(tempFile.id); return text; } catch (e) { return `{"error":"${e}" }` } } function test_getTextFromPdf() { var myId = "1vaZW6ufQCibgt2fTA-5V0tOp7TTjkYzx"; // the Id of a PDF file in Google Drive try { var content = getTextFromPdf(myId); console.log("SUCCESS! Text length: " + content.length); console.log(content); } catch (e) { console.log("Error: " + e.message); }
}
]]></description><link>examples/ocr-pdfs-with-google-drive-api.html</link><guid isPermaLink="false">Examples/OCR PDFs with Google Drive API.md</guid><pubDate>Tue, 17 Feb 2026 10:42:19 GMT</pubDate></item><item><title><![CDATA[Categorised KSPs Document Sidebar Addon]]></title><description><![CDATA[<img alt="Pasted image 20260120095055.png" src="examples/media/pasted-image-20260120095055.png" target="_self">This takes a JSON of KSBs (Knowledge, Skills, Behaviors) in JSON format, and makes a handy sidebar in Google Docs to insert Footnotes from the KSBs.<br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/document/d/1LLbWWFMEw1ZyFBAIwqyvFIMXGWEU9G_bkph3FHC9KEI" target="_self">https://docs.google.com/document/d/1LLbWWFMEw1ZyFBAIwqyvFIMXGWEU9G_bkph3FHC9KEI</a>]]></description><link>examples/categorised-ksps-document-sidebar-addon.html</link><guid isPermaLink="false">Examples/Categorised KSPs Document Sidebar Addon.md</guid><pubDate>Fri, 23 Jan 2026 14:29:41 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Running Long Running Scripts]]></title><description><![CDATA[THIS IS HOW YOU (OR I) SOLVE THE ISSUE of your script needing more time than the allowed time, and your script timeouts.Here is a working example.
<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1cocDUgZang9NxLNnn291X8BEnGOzx7Tgz15Tsf13tgo/edit?usp=sharing" target="_self">https://docs.google.com/spreadsheets/d/1cocDUgZang9NxLNnn291X8BEnGOzx7Tgz15Tsf13tgo/edit?usp=sharing</a> This is how I make scripts that run for days...weeks even...bearing in mind I don't oupace other quotas etc.First, imagine there's some process I need to do to a particular row, such as check it, and if it's ready then change its status or something.... doesn't matter what the function does.... just that it does it to a given rowIndex row.Create a Code tab and create a function like this... function prcocessRow( rowIndex, yourParam1, yourParam2 etc){
//do your stuff here...
} ... what goes in yourParam1 is up to you.... you might not need them at allHere's an example that I'm sure you can modify.... Now you need a Script that ...a. stores the currentRow in the Script properies.... so when the script runs again, it knows which row to start at.
b. When the script is running it checks to see how much time it has available (then takes a little off for breathing space)
c. When it runs out of time, it creates a Trigger that runs a function which looks at "a" and runs every TIMEOUT_MINUTES ... in my case it 30 minutes...
d. If all the rows have been done, then delete the Trigger and sends the user a report...
e. you also need a startFullProcess() function, just to get things running. Here's an example that I'm sure you can modify....// --- CONFIGURATION ---
// The name of the function the trigger will call.
const TRIGGER_FUNCTION_NAME = "continueProcessing";
const TIME_LIMIT_MINUTES = 20;
const RUN_EVERY_MINUTES = 30 // For the Trigger.
var documentsMadeCount = 0 //tally for the user report at the end // Runs ONCE manually to start the entire process. It clears any old state and creates the 30-minute trigger.
function startFullProcess() { Logger.log("Starting new process..."); PropertiesService.getUserProperties().deleteAllProperties(); // Clear any state from a previous run deleteTrigger();// Remove any old triggers to prevent duplicates createTrigger();// Create the trigger that will run the script every 30 minutes Logger.log("Trigger created. Running first batch now."); continueProcessing();// Run the main processing function for the first time
} /** // --- 2. MAIN TRIGGERED FUNCTION --- * This is the main function called by the trigger. * It picks up where it left off, processes rows one-by-one, and stops when the time limit is near. */
function continueProcessing() { const properties = PropertiesService.getUserProperties(); const startTime = new Date().getTime(); let rowIndex = parseInt(properties.getProperty('currentRowIndex') || '2'); // Assuming 1-based row indexing // DO YOUR STUFF HERE const ss = SpreadsheetApp.getActiveSpreadsheet(); const sheet = ss.getSheetByName("Marking") var sheetName = sheet.getName() var lastRow = sheet.getLastRow() var date = Utilities.formatDate(new Date(), "Europe/London", "dd/MM/yyyy"); try { // LOAD THE GLOBAL ITEMS YOU MAY NEED var destinationFolder = getDestinationFolder() // Get from Admin sheet var templateDocument = getTemplateDocument()// Get from Admin sheet // END LOAD GLOBALS //rowIndex = 2; // Reset row index for the next sheet properties.setProperty('currentRowIndex', rowIndex); Logger.log(`Processing sheet: "${sheetName}" Starting from row ${rowIndex}.`);// DO A SINGLE ROW LOGIC HERE! // Loop while there are rows left to process on this sheet while (rowIndex &lt;= lastRow) { processRow(rowIndex, sheet, destinationFolder, templateDocument, date) documentsMadeCount ++ // Increment row index for the next loop rowIndex++; const timeElapsed = (new Date().getTime() - startTime) / 1000 / 60; if (timeElapsed &gt; TIME_LIMIT_MINUTES) { properties.setProperty('currentRowIndex', rowIndex); Logger.log(`Time limit reached. Pausing. Next run will start at ${sheetName}, row ${rowIndex}.`); return; // Stop execution. Trigger will run again in 30 mins. } } // If we're here, the sheet is finished. Logger.log(`Finished sheet: "${sheetName}".`); rowIndex = 2; // Reset row index for the next sheet properties.setProperty('currentRowIndex', rowIndex); // If the 'for' loop completes, all sheets are done. Logger.log("All sheets processed successfully. Deleting trigger."); deleteTrigger(); properties.deleteAllProperties(); // Clean up state // Send email report for the user running the code var email = Session.getActiveUser().getEmail() var url = ss.getUrl() var body = ` &lt;p&gt;The creation of &lt;b&gt;${documentsMadeCount}&lt;/b&gt; The Marking AppsScript has finished.&lt;/p&gt; &lt;p&gt;Run on: ${date}&lt;/p&gt; &lt;p&gt;See the results in the &lt;a href="${url}"&gt; FORMATIVE MARKING&lt;/a&gt; sheet.&lt;/p&gt;` var options = { noReply: true, htmlBody: body } MailApp.sendEmail(email, "The FORMATIVE MARKING AppsScript Has Finished", '', options) Logger.log(`Email sent to ${email}`) ss.toast("Finished!") } catch (e) { Logger.log(`Error encountered: ${e}. Script will retry at next trigger.`); // Note: We don't delete the trigger. We want it to try again. // We also don't delete properties, so it retries the same batch. }
} // --- 4. TRIGGER HELPER FUNCTIONS ---
function createTrigger() { // Ensure no other triggers exist deleteTrigger(); ScriptApp.newTrigger(TRIGGER_FUNCTION_NAME) .timeBased() .everyMinutes(RUN_EVERY_MINUTES) .create();
} function deleteTrigger() { const allTriggers = ScriptApp.getProjectTriggers(); for (const trigger of allTriggers) { if (trigger.getHandlerFunction() === TRIGGER_FUNCTION_NAME) { ScriptApp.deleteTrigger(trigger); } }
} ]]></description><link>examples/running-long-running-scripts.html</link><guid isPermaLink="false">Examples/Running Long Running Scripts.md</guid><pubDate>Fri, 23 Jan 2026 14:29:07 GMT</pubDate></item><item><title><![CDATA[Pasted image 20260120095055]]></title><description><![CDATA[<img src="examples/media/pasted-image-20260120095055.png" target="_self">]]></description><link>examples/media/pasted-image-20260120095055.html</link><guid isPermaLink="false">Examples/media/Pasted image 20260120095055.png</guid><pubDate>Tue, 20 Jan 2026 09:50:55 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Scrape and Crawl a Website]]></title><description><![CDATA[
/** * CONFIGURATION * ========================== * Cheerio Library Id: 1ReeQ6WO8kKNxoaA_O0XEQ589cIrRvEBA9qcWpNqdOP17i47u6N9M5Xh0 * */ const CONFIG = { SAVE_MODE: 'FOLDER', // options: 'SINGLE_DOC' or 'FOLDER' START_URL: 'https://docs.page/DanielZamb/crud-for-sheets',// The starting URL to crawl DOMAIN: 'docs.page',// Domain to restrict crawling to (prevents crawling the whole internet) OUTPUT_NAME: 'CRUD for Sheets - Documentation Import',// Name of the output Google Doc or Folder DELAY_MS: 500 // Optional: Delay between requests to be polite (milliseconds)
}; /** * Main function to start the scraper. Run this function. */
function runScraper() { const visited = new Set(); const queue = [CONFIG.START_URL]; let mainDocOrFolder; // 1. Setup Output Destination if (CONFIG.SAVE_MODE === 'SINGLE_DOC') { // Create one single document const doc = DocumentApp.create(CONFIG.OUTPUT_NAME); mainDocOrFolder = doc; console.log(`Created Single Doc: ${doc.getUrl()}`); } else { // Create a folder const folder = DriveApp.createFolder(CONFIG.OUTPUT_NAME); mainDocOrFolder = folder; console.log(`Created Folder: ${folder.getUrl()}`); } // 2. Crawl Loop while (queue.length &gt; 0) { const currentUrl = queue.shift(); if (visited.has(currentUrl)) continue; visited.add(currentUrl); console.log(`Fetching: ${currentUrl}`); //try { // Fetch HTML const html = fetchHtml(currentUrl); if (!html) continue; // Load into Cheerio const $ = Cheerio.load(html); // Extract Content specifically for docs.page structure // docs.page usually puts content in a &lt;main&gt; tag or specific layout div const title = $('h1').first().text().trim() || 'Untitled Page'; // Clean up: Remove scripts, styles, navs, footers from the selection $('script').remove(); $('style').remove(); $('nav').remove(); $('footer').remove(); $('.hidden').remove(); // common utility class for hidden elements // Select the main content container. // On docs.page, content is often directly inside 'main' or a div with prose classes let $content = $('main'); if ($content.length === 0) $content = $('body'); // 3. Save Data if (CONFIG.SAVE_MODE === 'SINGLE_DOC') { appendPageToDoc(doc.getId(), title, currentUrl, $, $content); } else { createPageDoc(mainDocOrFolder, title, currentUrl, $, $content); } // 4. Find new links const links = $('a'); links.each((i, link) =&gt; { let href = $(link).attr('href'); if (href) { // Handle relative links if (href.startsWith('/')) { // FIX: Manual URL resolution to avoid "URL is not defined" error in some runtimes // Extract origin from START_URL (e.g., https://docs.page) const parts = CONFIG.START_URL.split('/'); const origin = parts.slice(0, 3).join('/'); href = origin + href; } // Only add if it belongs to the same documentation path and hasn't been visited if (href.includes(CONFIG.DOMAIN) &amp;&amp; href.includes('DanielZamb/crud-for-sheets') &amp;&amp; !visited.has(href) &amp;&amp; !queue.includes(href) &amp;&amp; !href.includes('#')) { // ignore anchor links queue.push(href); } } }); // Be polite to the server Utilities.sleep(CONFIG.DELAY_MS); //} catch (e) { //console.error(`Failed to process ${currentUrl}: ${e.message}`); //} } console.log('--- Scraping Complete ---');
} /** * Fetches HTML content from a URL */
function fetchHtml(url) { try { const response = UrlFetchApp.fetch(url, { muteHttpExceptions: true }); if (response.getResponseCode() === 200) { return response.getContentText(); } return null; } catch (e) { console.warn(`Error fetching ${url}: ${e.message}`); return null; }
} /** * Appends formatted content to a Single Google Doc */
function appendPageToDoc(docId, title, url, $, $content) { var doc = DocumentApp.openById(docId) const body = doc.getBody(); // Add Page Header body.appendParagraph(title).setHeading(DocumentApp.ParagraphHeading.HEADING1); body.appendParagraph(`Source: ${url}`).setHeading(DocumentApp.ParagraphHeading.SUBTITLE); // Parse and Append HTML structure parseHtmlToDocBody($, $content, body); // Add a divider body.appendHorizontalRule(); body.appendPageBreak(); //doc.saveAndClose(); // Save periodically //let docId = doc.getId() //doc = DocumentApp.openById(docId); // Re-open to avoid memory issues on huge crawls
} /** * Creates a separate Google Doc for a page inside a Folder */
function createPageDoc(folder, title, url, $, $content) { // Sanitize title for filename const safeTitle = title.replace(/[^a-zA-Z0-9-_ ]/g, '').substring(0, 50); const doc = DocumentApp.create(safeTitle); const body = doc.getBody(); // Add Header body.appendParagraph(title).setHeading(DocumentApp.ParagraphHeading.HEADING1); body.appendParagraph(`Source: ${url}`).setHeading(DocumentApp.ParagraphHeading.SUBTITLE); // Parse Content parseHtmlToDocBody($, $content, body); let docId = doc.getId() doc.saveAndClose(); // Move file to the correct folder (files are created in root by default) const file = DriveApp.getFileById( docId ); file.moveTo(folder);
} /** * Helper to traverse Cheerio elements and map them to Google Doc methods. * This is a simplified parser to make text readable for NotebookLM. */
function parseHtmlToDocBody($, $container, body) { $container.children().each((i, el) =&gt; { const $el = $(el); const tag = el.name.toLowerCase(); const text = $el.text().trim(); if (!text &amp;&amp; tag !== 'img' &amp;&amp; tag !== 'hr') return; try { switch (tag) { case 'h1': body.appendParagraph(text).setHeading(DocumentApp.ParagraphHeading.HEADING1); break; case 'h2': body.appendParagraph(text).setHeading(DocumentApp.ParagraphHeading.HEADING2); break; case 'h3': body.appendParagraph(text).setHeading(DocumentApp.ParagraphHeading.HEADING3); break; case 'h4': case 'h5': case 'h6': body.appendParagraph(text).setHeading(DocumentApp.ParagraphHeading.HEADING4); break; case 'p': body.appendParagraph(text).setHeading(DocumentApp.ParagraphHeading.NORMAL); break; case 'ul': $el.find('li').each((j, li) =&gt; { body.appendListItem($(li).text().trim()).setGlyphType(DocumentApp.GlyphType.BULLET); }); break; case 'ol': $el.find('li').each((j, li) =&gt; { body.appendListItem($(li).text().trim()).setGlyphType(DocumentApp.GlyphType.NUMBER); }); break; case 'pre': case 'code': // NotebookLM handles code blocks well if they are distinct const codePara = body.appendParagraph(text); codePara.setFontFamily('Courier New'); codePara.setBackgroundColor('#f3f3f3'); break; case 'div': case 'section': case 'article': // Recursively parse layout containers parseHtmlToDocBody($, $el, body); break; default: // Fallback for other tags, just append text if it's block level if (['div', 'section'].includes(tag)) { parseHtmlToDocBody($, $el, body); } else if (text.length &gt; 0) { body.appendParagraph(text); } } } catch (e) { // Ignore formatting errors to keep the crawler moving console.warn('Formatting skip:', e.message); } });
} ]]></description><link>examples/scrape-and-crawl-a-website.html</link><guid isPermaLink="false">Examples/Scrape and Crawl a Website.md</guid><pubDate>Mon, 19 Jan 2026 10:42:24 GMT</pubDate></item><item><title><![CDATA[Export AppsScript Project]]></title><description><![CDATA[
/** NOT USED! - was just wondering how to continue working on this app without clasp. * * Exports all files in the current AppsScript project to a new folder in Google Drive. * * IMPORTANT: * 1. Go to https://script.google.com/home/usersettings and enable "Google Apps Script API". * 2. This script requires 'https://www.googleapis.com/auth/drive' and * 'https://www.googleapis.com/auth/script.projects.readonly' scopes. */
function exportProjectFilesToDrive() { try { const scriptId = ScriptApp.getScriptId(); const accessToken = ScriptApp.getOAuthToken(); // 1. Get Project Metadata to find the project name const metadataUrl = `https://script.googleapis.com/v1/projects/${scriptId}`; const metadataResponse = UrlFetchApp.fetch(metadataUrl, { headers: { Authorization: `Bearer ${accessToken}` } }); const projectTitle = JSON.parse(metadataResponse.getContentText()).title; // 2. Fetch the actual project content (files) const contentUrl = `https://script.googleapis.com/v1/projects/${scriptId}/content`; const contentResponse = UrlFetchApp.fetch(contentUrl, { headers: { Authorization: `Bearer ${accessToken}` } }); const projectData = JSON.parse(contentResponse.getContentText()); if (!projectData.files || projectData.files.length === 0) { Logger.log("No files found in this project."); return; } // 3. Create a folder in Google Drive with the project name // We add a timestamp to avoid confusion if running multiple times const folderName = `${projectTitle} _Exported_${new Date().toLocaleString()}`; const folder = DriveApp.createFolder(folderName); Logger.log(`Created folder: ${folderName}`); // 4. Iterate through project files and create them in the Drive folder projectData.files.forEach(file =&gt; { let fileName = file.name; const content = file.source; // Append appropriate extensions based on type // AppsScript API returns 'SERVER_JS' for .gs and 'HTML' for .html if (file.type === 'SERVER_JS') { fileName += '.gs'; } else if (file.type === 'HTML') { fileName += '.html'; } else if (file.type === 'JSON') { fileName += '.json'; } folder.createFile(fileName, content); Logger.log(`- Created file: ${fileName}`); }); console.log(`Success! All files exported to folder: ${folder.getUrl()}`); } catch (error) { console.error("Export failed: " + error.toString()); if (error.toString().includes("403")) { console.error("Permission Error: Ensure 'Google Apps Script API' is enabled at https://script.google.com/home/usersettings"); } }
} /** * Helper to ensure the necessary scopes are prompted when the script is authorized. */
function triggerPermissions() { DriveApp.getRootFolder();
}
]]></description><link>examples/export-appsscript-project.html</link><guid isPermaLink="false">Examples/Export AppsScript Project.md</guid><pubDate>Fri, 16 Jan 2026 13:48:30 GMT</pubDate></item><item><title><![CDATA[A Folder Per Person Uploading Files]]></title><description><![CDATA[<a rel="noopener nofollow" class="external-link is-unresolved" href="https://drive.google.com/drive/folders/1f24HYtMdbumHrt4FL5sEKPOyu3IVB-3x" target="_self">https://drive.google.com/drive/folders/1f24HYtMdbumHrt4FL5sEKPOyu3IVB-3x</a>]]></description><link>examples/a-folder-per-person-uploading-files.html</link><guid isPermaLink="false">Examples/A Folder Per Person Uploading Files.md</guid><pubDate>Wed, 17 Dec 2025 11:32:32 GMT</pubDate></item><item><title><![CDATA[Index]]></title><description><![CDATA[This is collection of script fragments, mini projects, working examples of things you can do with AppsScript.Explore the Examples at the left hand side, some link to Google Sheets where you can poke around the code using the menu Extensions &gt; AppsScript. Some have code samples. Most are only available to UoY people and assume a working knowledge of the basics of AppsScripts. Some might not work because the authentication has rotted over time. In most cases I'd recommend making your own copy of the sheet and working with that.For people doing more interesting and advanced things, do take a look around <a data-tooltip-position="top" aria-label="https://github.com/tanaikech" rel="noopener nofollow" class="external-link is-unresolved" href="https://github.com/tanaikech" target="_self">Kanshi Tanaike's</a> github repository of gists, examples and tutorials (amazing) and the <a data-tooltip-position="top" aria-label="https://pulse.appsscript.info/" rel="noopener nofollow" class="external-link is-unresolved" href="https://pulse.appsscript.info/" target="_self">AppsScript Pulse website</a>.<br>*Handy Tip: This Google Drive search is worth bookmarking, <a data-tooltip-position="top" aria-label="https://drive.google.com/drive/search?q=type:application/vnd.google-apps.script%20owner:me" rel="noopener nofollow" class="external-link is-unresolved" href="https://drive.google.com/drive/search?q=type:application/vnd.google-apps.script%20owner:me" target="_self">Show my Apps Script files and projects in Google Drive</a>, because it gives different, and my opinion better results than just searching Google Drive for script related content.*]]></description><link>index.html</link><guid isPermaLink="false">Index.md</guid><pubDate>Mon, 08 Sep 2025 07:03:26 GMT</pubDate></item><item><title><![CDATA[Drive API v2 Lister]]></title><description><![CDATA[Gets all the files in a Google Drive folder and saves into a sheet.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1c0o-vwxZPl7zAUJLmHiu3L-08cHWKDGxyq06U3WvHIA/edit?gid=0#gid=0" target="_self">https://docs.google.com/spreadsheets/d/1c0o-vwxZPl7zAUJLmHiu3L-08cHWKDGxyq06U3WvHIA/edit?gid=0#gid=0</a>]]></description><link>examples/drive-api-v2-lister.html</link><guid isPermaLink="false">Examples/Drive API v2 Lister.md</guid><pubDate>Thu, 04 Sep 2025 13:31:16 GMT</pubDate></item><item><title><![CDATA[Get JSON attributes in Sheet's Cell]]></title><description><![CDATA[This code, found on Reddit is way to get attributes from a JSON array or object.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1eqFShPdQzjdbsqnTLm7oIpCwr2U1mJZHbVVqBidMNsg/edit?gid=0#gid=0" target="_self">https://docs.google.com/spreadsheets/d/1eqFShPdQzjdbsqnTLm7oIpCwr2U1mJZHbVVqBidMNsg/edit?gid=0#gid=0</a>]]></description><link>examples/get-json-attributes-in-sheet's-cell.html</link><guid isPermaLink="false">Examples/Get JSON attributes in Sheet's Cell.md</guid><pubDate>Tue, 15 Jul 2025 13:51:33 GMT</pubDate></item><item><title><![CDATA[York Staff Scraper]]></title><description><![CDATA[
/*
Uses Cheerio (jQuery) Library Id : 1ReeQ6WO8kKNxoaA_O0XEQ589cIrRvEBA9qcWpNqdOP17i47u6N9M5Xh0
Uses the People API Service - add to your project This example reads the Staff website and scrapes data from the page to use on the People sheet.
*/ var homeUrl = "https://www.york.ac.uk/healthsciences/research/trials/staff/" /////////////////////// RUN THIS FUNCTION ////////////////////////////////////////////
function doScrapeSite() { scrapeSite(homeUrl)
}
/////////////////////////// END ////////////////////////////////////////////////////// function scrapeSite(homeUrl) { let sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("People") sheet.clear() sheet.appendRow(["Prefix", "Name", "Email", "Position", "URL"]) sheet.setFrozenRows(1); sheet.getRange(1, 1, 1, sheet.getLastColumn()).setFontWeight("bold") let html = UrlFetchApp.fetch(homeUrl).getContentText() const $ = Cheerio.load(html); //Make it jQuerable let links = [] //Find all the URLs from the navigation $("li").each(function (i, elem) { var l = $(this).find("a").attr("href") if (l == undefined | l == "undefined") { } else { if (l.indexOf("/healthsciences/our-staff") &gt; -1) {//Can't do bloody LinkedIn or nottingham.ac.uk if (l.indexOf("https://www.york.ac.uk") &gt; -1) { var link = l } else { var link = "https://www.york.ac.uk" + l//ugh! } if (links.indexOf(link) &gt; -1) {// Have we seen it before? } else { if (link.toString() != 'undefined') { if (links.indexOf(link) == -1) { Logger.log(link) links.push(link) //so we can keep track of the ones we've seen } } } } } }) Logger.log(links.length + " to crawl") //Let's go the indivual pages sheet.setRowHeights(2,links.length, 100) for (l in links) { var url = links[l] if (url == undefined) { } else { try{ var data = getData(url) sheet.appendRow(data) Utilities.sleep(300)//So we don't blow it up }catch(e){ Logger.log("Error with URL: " + url) } } }
} ////////////////////////// GET A PERSON'S INFO //////////////////////////////// function test_getData() { //of an indidual's profile var url = "https://www.york.ac.uk/healthsciences/our-staff/rachel-bottomley-wise/" Logger.log(getData(url))
} /** * getData(url) * * @param {string} url what you want scraping * returns {string} html of that page */
function getData(url) { try { let html = UrlFetchApp.fetch(url).getContentText() const $ = Cheerio.load(html); let prefix = $("span.honorific-prefix").first().html().trim() let firstName = $('span.given-name').first().html().trim() let surname = $('span.family-name').first().html().trim() let email = $('.icon').first().html().trim() let title = decodeURIComponent($('.title').first().html()).trim() title = title.replace("&amp;amp;", "&amp;") Logger.log(`${firstName} ${surname} - ${email}`) var imgURL = getUserPictureUrl(email) var displayImageFormula = `=IMAGE("${imgURL}",4, 100, 100)` return [prefix, firstName + " " + surname, email, title, url, imgURL,displayImageFormula] } catch (e) { Logger.log(e + " " + e.stack) } } function test_emailToImage() { Logger.log(getUserPictureUrl("tom.smith@york.ac.uk"))
} /*
This gets a person's profile picture as is set in their Google account, and reveals a
publicly accessible url for an icon.
*/
function getUserPictureUrl(email) { let defaultPictureUrl = 'https://lh3.googleusercontent.com/a-/AOh14Gj-cdUSUVoEge7rD5a063tQkyTDT3mripEuDZ0v=s100'; try { if (email == '' | email == null) { return defaultPictureUrl } let people = People.People.searchDirectoryPeople({ query: email, readMask: 'photos', sources: 'DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE' }); //Logger.log( people?.people[0]?.photos ) let userPictureUrl = people?.people[0]?.photos[0]?.url; //Logger.log(userPictureUrl) return userPictureUrl ?? defaultPictureUrl; } catch (e) { Logger.log("Error: " + email + " " + e) return defaultPictureUrl } } // Goes through the entire sheet of data and does every one
function getAllProfilePictures() { var ss = SpreadsheetApp.getActiveSpreadsheet() var sheet = ss.getSheetByName("Trial Teams") var rows = sheet.getDataRange().getValues() var headers = rows.shift() for (i = 0; i &lt; rows.length; i++) { var row = rows[i] var rowNum = i + 2 var email = row[2].trim() //Zero-based var imageURL = row[3] if (imageURL == "https://lh3.googleusercontent.com/a-/AOh14Gj-cdUSUVoEge7rD5a063tQkyTDT3mripEuDZ0v=s100" | imageURL ==''){ Logger.log(email) var imageURL = getUserPictureUrl(email) sheet.getRange("D" + rowNum).setValue(imageURL) //1-based var formula = `=IMAGE(D${rowNum}, 4, 100, 100)` sheet.getRange("E" + rowNum).setFormula(formula) //1-based Utilities.sleep(3000)// wait for 3 seconds } } Logger.log("Done!") sheet.setRowHeights(2, rows.length, 100 )
} ]]></description><link>examples/york-staff-scraper.html</link><guid isPermaLink="false">Examples/York Staff Scraper.md</guid><pubDate>Tue, 15 Jul 2025 09:19:20 GMT</pubDate></item><item><title><![CDATA[Get Data From Google Document Smart Chips]]></title><description><![CDATA[Imagine you have a Google Document for project management and the first table in it looks like this and uses SmartChips.<img alt="Pasted image 20250708154228.png" src="examples/media/pasted-image-20250708154228.png" target="_self">Google don't let you get the data from the table because dur! The script below shows you how you can get all the cells of the table in a format you can loop through them and do wonderful things with, like add them to a sheet or something.
/*
Note: Requires the Service: Drive API v3 adding to your script -- see left hand side
Note: It's not super fast but seems reliable. Could be used in a daily checker script etc.
Note: Not tested on all Smartchip types yet, only People, Dropdowns and Dates This is for when you want to get the value from a Google Doc Table in which you have Smart Chips.
Helpfully, Google's New Product Team don't talk to the AppsScript team, so you can't get data if people have used SmartChips FFS. BUT NOW YOU CAN!!! ... If a document has tables, and the tables have Smart chips in known locations.
So you use this, like this... var values = getValuesFromTable(DOCUMENT_ID, TABLE_INDEX, TEMP_FOLDER_ID); ...it will return a structured Array, with the same dimensions as your table like this... [[Task tracker, , , , ], [ Assignee, Project, Action, Date, Status], [{email=tom.smith@york.ac.uk, name=Tom Smith}, ECA, Do something with AI that doesn't suck, Jun 22, 2025, In progress], [{email=phil.bainbridge@york.ac.uk, name=Phil Bainbridge}, Other, Sorting out Google so you don't have to, Jun 19, 2025, Completed], [, , , , ], [, , , , ]] So to get the first row's Assignee (which is a Smart Chip for a person) you might... var person = getValuesFromTable(DOCUMENT_ID, TABLE_INDEX)[2][0] Logger.log(person.name) Logger.log(person.email) */
function test_getValuesFromTable() { var DOCUMENT_ID = 'CHANGE THIS TO YOUR DOC ID'; /// EDIT THIS TO YOUR DOC ID var TABLE_INDEX = 0; // WHAT IS THE INDEX OF THE TABLE YOU WANT? // This is just a scratch folder that you need for this script to work, where temp files get created (and automatically deleted) var TEMP_FOLDER_ID = "CHANGE THIS TO A TEMP FOLDER ID YOU'VE MADE" // Do the magic here var contents = getValuesFromTable(DOCUMENT_ID, TABLE_INDEX, TEMP_FOLDER_ID); if (contents) { Logger.log('Successfully retrieved table contents:'); Logger.log(contents) Logger.log(contents[2][0]) /* // Log all the contents in a structured way contents.forEach((row, rowIndex) =&gt; { row.forEach((cellValue, cellIndex) =&gt; { Logger.log(`Row: ${rowIndex}, Cell: ${cellIndex}, Content: "${cellValue}"`); }); }); //*/ } else { Logger.log('Failed to retrieve table contents. Please check the Document ID and Table Index.'); } } function getValuesFromTable(docId, tableIndex = 0, tempFolderId) { // https://drive.google.com/drive/folders/1Qqg3_pg4FEwznDkcvmKE1NBRJwcEmB9q?ndplr=1 try { var tempFilesFolder = DriveApp.getFolderById(tempFolderId) var docUrl = Drive.Files.download(docId).response.downloadUri var gDocBlob = UrlFetchApp.fetch(docUrl, { headers: { authorization: "Bearer " + ScriptApp.getOAuthToken() }, }).getBlob() var tempDocumentId = Drive.Files.create({ name: "temp_" +docId, mimeType: 'application/vnd.google-apps.document' }, gDocBlob).id var tempDocument = DriveApp.getFileById(tempDocumentId) tempDocument.moveTo(tempFilesFolder) var alternateDoc = DocumentApp.openById(tempDocumentId) var alternateBody = alternateDoc.getBody() var altTables = alternateBody.getTables() // Open and Read the table var doc = DocumentApp.openById(docId)//The original Doc var body = doc.getBody() // Check if the requested table index is valid var tables = body.getTables() if (tableIndex &lt; 0 || tableIndex &gt;= tables.length) { Logger.log(`Error: Table with index ${tableIndex} not found.`); Logger.log(`The document has ${tables.length} tables.`); return null; } var table = tables[tableIndex]; var altTable = altTables[tableIndex] var tableContents = []; // Get the number of rows in the table var numRows = table.getNumRows(); // Iterate over each row in the table for (let i = 0; i &lt; numRows; i++) { var row = table.getRow(i); var rowContents = []; // Get the number of cells in the current row var numCells = row.getNumCells(); // Iterate over each cell in the row for (let j = 0; j &lt; numCells; j++) { var cell = row.getCell(j); // Get the text content of the cell and push it to the row array var contents = cell.getText() if (contents == ""){ // Try the alternatives, you never know, go fishing contents = getSmartChipData(table, altTable, i, j) } rowContents.push(contents); } // Push the row array to the main table contents array tableContents.push(rowContents); } try{ DriveApp.getFileById(tempDocumentId).setTrashed(true); }catch(e){ } return tableContents; } catch (e) { try{ DriveApp.getFileById(tempDocumentId).setTrashed(true); }catch(e){ } Logger.log(`An error occurred: ${e.message}`); return null; } } ////////////////////////// TRY SMART CHIPS /////////////////////////////////////
function getSmartChipData(table, altTable, rowIndex, cellIndex) { var cell = table.getRow(rowIndex).getCell(cellIndex) var cellValue = '' // People Chips MULTIPLE People Chips try{ var people = [] //Gather up multiple people for (c=0; c &lt;cell.getChild(0).getNumChildren() ; c++){ var subCell = cell.getChild(0).getChild(c) var name = subCell.getName() var email = subCell.getEmail() people.push( {name:name, email:email}) } //Return either a person or an array of people if (people.length == 1){ var person = people[0] Logger.log(person) return person }else{ Logger.log( people ) return people } }catch(e){ //Logger.log( "Couldn't find it in a People Chip: " + e) } //End People Chips //Date Chips try{ var subCell = cell.getChild(0).getChild(0).asDate() var cellValue = subCell.getDisplayText() return cellValue }catch(e){ //Logger.log( "Couldn't find it in a Date Chip") } //OK. Now we're in trouble lets go fishing try{ var cell = altTable.getRow(rowIndex).getCell(cellIndex) var cellValue = cell.getText() return cellValue }catch(e){ Logger.log( "Couldn't find it in the ALT Doc, maybe it's just empty") } return "" }
///////////////////////// END TRY SMART CHIPS ////////////////////////// ]]></description><link>examples/get-data-from-google-document-smart-chips.html</link><guid isPermaLink="false">Examples/Get Data From Google Document Smart Chips.md</guid><pubDate>Thu, 10 Jul 2025 08:25:32 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Pasted image 20250708154228]]></title><description><![CDATA[<img src="examples/media/pasted-image-20250708154228.png" target="_self">]]></description><link>examples/media/pasted-image-20250708154228.html</link><guid isPermaLink="false">Examples/media/Pasted image 20250708154228.png</guid><pubDate>Tue, 08 Jul 2025 14:42:28 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Scripting Scripts]]></title><description><![CDATA[ ////////////////////////////// START Objectify.gs ////////////////////////////// /*
I often don't like working with lists of data. Sometimes I want to work with Javascript objects.
It means people can, in theory add or rename columns and you can at least recover. */ /*
objectify(Array headers, ArrayList rows): Used to turn rows of data into a list of named objects, like this: [{hair=None, name=Bert, age=81.0}, {hair=Blond, name=Sally, age=34.0}, {hair=Black, name=Alf , age=68.0}, {hair=None, name=Stan, age=69.0}, {hair=Grey, name=Hilda, age=66.0}, {hair=Ginger, name=Fred, age=44.0}] so you can: var objectList = objectify(headers, rows) Logger.log( objectList[1].hair) //get 2nd row's hair * @param &lt;Array&gt; column headers as list
* @param &lt;Array&gt; rows as list
* @return [{}, {}...] a list of objects
*/ function objectify(headers, rows){ var newarray=[] var obj for(var y = 0; y &lt; rows.length; y++){ obj = {}; for(var i = 0; i &lt; headers.length; i++){ obj[headers[i]] = rows[y][i]; } newarray.push(obj) } return newarray } function objectifyOneRow(headers, row){ obj = {}; for(var i = 0; i &lt; headers.length; i++){ //Logger.log( headers[i] + " - " + row[i] ) obj[ headers[i] ] = row[i]; } return obj } function rangeToObj(range){ var headers = ["From Form", "Replace With"] var rows = range.getValues() var key = headers[0] var obj = objectifyWithKey(headers, rows, key) return obj
} /*
* @param &lt;Sheet&gt; the sheet you want to use
* @param &lt;Integer&gt; row number
* @param &lt;Integer&gt; header row number, usually 1 * @return {} a single object
*/ function objectifyOne( sheet, rowNum, headerRowNum){ var hNum = headerRowNum | 1 //Uses 1 if none passed in. var headers = sheet.getRange( hNum, 1, 1, sheet.getLastColumn()).getValues()[0] var data = sheet.getRange( rowNum,1, 1, sheet.getLastColumn()).getValues() var object = objectify(headers, data)[0] return object }
function test_objectifyOne(){ logSheet.getRange("A4").setValue( objectifyOne( sheet, 52))
} /*////////////// objectifyWithKey(Array headers, ArrayList rows, String key): returns: {Sally={hair=Blond, name=Sally, age=34.0}, Stan={hair=None, name=Stan, age=69.0}, Bert={hair=None, name=Bert, age=81.0}, Alf ={hair=Black, name=Alf , age=68.0}, Fred={hair=Ginger, name=Fred, age=44.0}, Hilda={hair=Grey, name=Hilda, age=66.0}} so you can: var objectDict = objectifyWithKey(headers, rows, "name") //key has to be unique, otherwise will overwrite Logger.log(objectDict['Fred'].hair) //Note I can get a row by its keyed item. &gt;&gt; "Ginger"
} *
* @param &lt;Array&gt; headers
* @param &lt;Array&gt; rows
* @param &lt;String&gt; key name * @return [{},{}] A list of objects.
*/ function objectifyWithKey(headers, rows, key){ var newarray=[] var keyedObj = {} for(var y = 0; y &lt; rows.length; y++){ obj = {rowNum: y+2}; for(var i = 0; i &lt; headers.length; i++){ obj[headers[i]] = rows[y][i]; } keyedObj[obj[key]] = obj //leave the key in } return keyedObj } /////////////////////////////// END Objectify.gs /////////////////////////////// //////////////////////////// START onFormSubmit.gs //////////////////////////// ///////////////////// GLOBAL VARS ///////////////////////// var ss = SpreadsheetApp.getActiveSpreadsheet()
var responsesSheet = ss.getSheetByName("Form responses 1")
var adminActionsSheet = ss.getSheetByName("Admin: Actions")//Mm?! should I global this?
var toProcessSheet = ss.getSheetByName("To Process")//Mm?! should I global this?
var toProcessHeaders = toProcessSheet.getRange(1, 1, 1, toProcessSheet.getLastColumn()).getValues()[0]//Could cache this //Sigh this uses a different object type...
var adminActionRows = adminActionsSheet.getDataRange().getValues()//getCachedAdminRows()//
var adminHeaders = adminActionRows.shift()
var adminActionObjs = objectifyWithKey(adminHeaders, adminActionRows, "Dropdown Name")
///////////////////// END VARS ///////////////////////// function onFormSubmit(e) { try { var rowNum = e.range.getRow() handleFormSubmit(rowNum) } catch (e) { logError(e + " " + e.stack) } Logger.log("Done!")
} function authorise(){ Logger.log("Do authorise")//Just used to check we have Auth.
} function logError(errorStr){ Logger.log(errorStr) var sheetURL = "https://docs.google.com/spreadsheets/d/1pgmGP-dYDi3nucRoJcbIBwfut79Tjnw07K2rGDFNKGY/edit#gid=1338898018" var errorLogSheet = ss.getSheetByName("Admin: Error Log") errorLogSheet.appendRow([new Date(), errorStr]) //Tell Tom var options = {cc:"lib-orders@york.ac.uk ", noReply:true} MailApp.sendEmail("tom.smith@york.ac.uk,", "Tell Us What You Need Error", errorStr + "\n\n" + sheetURL, options)
} ///////////////////////////// END onFormSubmit.gs ///////////////////////////// //////////////////////////// START handleSubmit.gs //////////////////////////// // By taking the handleFormSubmit out, you can run it independently. function reprocessFormResponses(){ //Used for testing purposes only... for (i=125; i&gt;1; i++){ handleFormSubmit(i) Loggerlog( "Done! " + i) Utilities.sleep(2000) }
}
function test_handleSubmit() {//So we can pump n dump a form submission // Values logged at start. var rowNum = 48 // the row we want to do. handleFormSubmit(rowNum) } function handleFormSubmit(rowNum) { Logger.log("handleFormSubmit: " + rowNum) var adminStatusesSheet = ss.getSheetByName("Admin: Statuses") //Objectify returns an {Object} of that row. It makes it easier to work with. let obj = objectifyOne(responsesSheet, rowNum, 1) var statusObj = rangeToObj(adminStatusesSheet.getRange("A2:B" + adminStatusesSheet.getLastRow()))//An object of statuses // GET PERSONAL DATA var timestamp = obj['Timestamp'] var email = obj['Email address'] var name = obj['Your name'] var username = obj['Your username'] var department = obj['Your Department or School'] var status = obj['Are you'] status = statusObj[status]['Replace With'] //Look up shortened form var requestType = obj['What type of request would you like to make?'] // END GET PERSONAL DATA var id = Utilities.getUuid();//fake not used!!!!!!! //SPLIT THE ITEMS INTO THEIR OWN ROWS var items = getItems(obj) var chapters = getChapters(obj) var journals = getJournals(obj) Logger.log(`items:${items.length} , chapters:${chapters.length} , journals:${journals.length}`) //MOVE THE SHEET BASED ON THEIR requestType switch (requestType) { case 'Whole Book or Thesis': // move to "To Process" sheet moveToProcessSheet(timestamp,name, email, username, department, status, requestType,id, items) break; case 'Book Chapter': // move to "Alma Borrowing Requests" sheet moveToAlmaSheet(timestamp,name, email, username, department, status, requestType,id, chapters) break; case 'Journal Article': // move to "Alma Borrowing Requests" sheet moveToAlmaSheet(timestamp,name, email, username, department, status, requestType,id, journals) break; default: // Should not get here... Logger.log("Eek! We got here! in handleFormSubmit: " + requestType) } Logger.log("Done! " + new Date()) } ///////////////////////////// END handleSubmit.gs ///////////////////////////// ////////////////////////// START sheetMaintenance.gs ////////////////////////// function sheetMaintainance(){ // Actually delete all hidden rows on the To Process sheet... remember to do backwards var values = toProcessSheet.getDataRange().getValues() var headers = values.shift() for (i=values.length-1; i&gt;=0; i--){ // NOTE BACKWARDS var rowNum = i+2 var isHidden = toProcessSheet.isRowHiddenByUser(rowNum) if (isHidden == true){ Logger.log(`${i} rowNum:${rowNum} isHidden: ${isHidden}`) toProcessSheet.deleteRow(rowNum) Utilities.sleep(300) } } // Make sure enough spares exist for the copyTo. checkToSeeIfExtraRowsNeedAddingToCompletedSheet() Logger.log("Maintenance done!")
} function checkToSeeIfExtraRowsNeedAddingToCompletedSheet(){ var numOfRows = 100 var completedSheet = ss.getSheetByName("Completed") var lastRow = completedSheet.getLastRow() Logger.log("Last row is " + lastRow) var maxRows = completedSheet.getMaxRows()//includes empty rows Logger.log("Max rows is " + maxRows) var nextRow = lastRow + numOfRows Logger.log("Next row is " + nextRow) //var isBlank = completedSheet.getRange("A"+ nextRow).isBlank()//doesn't really help as it's true if not even there. //Logger.log("Is it blank? " + isBlank ) if (nextRow &gt; maxRows){ //we need more Logger.log(`Adding ${numOfRows} extra blank rows`) // lastRow + numOfRows is greater than we have available //So add some rows... var nextAvailableRowNum = lastRow + 1 completedSheet.insertRowsAfter(lastRow, numOfRows) } SpreadsheetApp.flush() Logger.log("Almost done...") var lastRow = completedSheet.getLastRow() Logger.log("Last row is now " + lastRow)//should be the same... var maxRows = completedSheet.getMaxRows()//includes empty rows Logger.log("Max rows is now " + maxRows)//should be bigger Logger.log("Really done now!") } function getAnEmptyArray(len){ // returns a list len long, with just a list, with an empty string in each sub-list. return Array.from(Array(len), (_, i) =&gt; [''])
}
function test_getAnEmptyArray(){ Logger.log( getAnEmptyArray(100) )
} /////////////////////////// END sheetMaintenance.gs /////////////////////////// ////////////////////////// START Menu and Actions.gs ////////////////////////// var DEBUG = false // if true this turns ON the use of LockService ///////////////////////////// START IMPORTANT DYNAMIC MENUS /////////////////// function installFunctions() { Logger.log("Rebuilding dynamic menus") //var adminActionObjs = getAdminActionObjs() //Can't cache within anonymous function like I thought var menu = SpreadsheetApp.getUi().createMenu("🔴 Change status of selected row"); //var adminActionObjs = getAdminMenuItems()//Doesn't use cache... for (var key in adminActionObjs) {//see Admin:Actions sheet var functionName = adminActionObjs[key]['Function Name'] var status = adminActionObjs[key]['Dropdown Name'] //Ordered Ebook (sends email), Ordered Print Fast (sends email) etc var dialogMessageForUser = adminActionObjs[key]['Dialog Message For User'] var emailTemplate = adminActionObjs[key]['Email Template'] //Logger.log( `${status}, ${functionName}`) if (functionName != ''){ var statusName = status.replaceAll(" ", "_") statusName = statusName.replaceAll("(", "") statusName = statusName.replaceAll(")", "") this[statusName] = doHandleAdminMenu(status, functionName, dialogMessageForUser, emailTemplate); menu.addItem(status, statusName); } // I could add cached items here maybe? } menu.addToUi();
} try{ installFunctions(); // This function is run when the Spreadsheet is opened and each menu is selected
}catch(e){ Logger.log( "Can't run this from onFormSubmit")
} function doHandleAdminMenu(status, functionName, dialogMessageForUser, emailTemplate) { return function () { handleAdminMenu(status, functionName, dialogMessageForUser, emailTemplate) };
}
///////////////////////////// END IMPORTANT DYNAMIC MENUS /////////////////// function onOpen(e) { var ui = SpreadsheetApp.getUi(); var menu = ui.createMenu('Admin') menu.addItem('Add Id to selected cell', 'addIdToSelectedCell') .addItem("handleFormSubmit for selected row", "doHandleFormSubmit") .addItem("Move selected row back to 'To Process' sheet", "moveRowBackToToProcessSheet") .addItem("Delete Hidden Rows", "sheetMaintainance") .addSeparator(); menu.addSubMenu(ui.createMenu('Resend ') .addItem('Selected Book To Alma', 'fromMenuReDoBook') .addItem('Selected Chapter To Alma', 'fromMenuReDoChapter') .addItem('Selected Journal To Alma', 'fromMenuReDoJournal') ) .addSeparator(); menu.addSubMenu(ui.createMenu('Move selected row from "To Process" to') .addItem("Alma Chapter Requests", 'moveFromToProcessToChapters') .addItem("Alma Journal Requests", 'moveFromToProcessToJournals') ).addToUi(); } function handleAdminMenu(status, functionName, dialogMessageForUser, emailTemplate) { var activeCell = ss.getActiveCell() var theSheet = activeCell.getSheet() var sheetName = theSheet.getName() var rowNum = activeCell.getRow() Logger.log(`handleAdminMenu: rowNum:${rowNum}, status:${status}, functionName:${functionName} ` + new Date()) if (sheetName == "To Process" &amp; rowNum &gt; 1) { /*if (DEBUG == true){ Logger.log("Locking...") var lock = LockService.getScriptLock(); lock.waitLock(20000);//20 seconds }*/ //var headers = getCachedHeaders() var row = theSheet.getRange(rowNum, 1, 1, theSheet.getLastColumn()).getValues()[0]//using global headers var obj = objectifyOneRow(toProcessHeaders, row) var id = obj['id'] Logger.log(`handleAdminMenu: rowNum:${rowNum}, id:${id}`) //var adminActionObjs = getAdminActionObjs() var adminActionObj = adminActionObjs[status] var colour = adminActionObj['Colour'] //This could be sped up perhaps with literal strings... var message = adminActionObj['Dialog Message For User'].toString() //var messateTemplate = HtmlService.createTemplate(message) //messateTemplate.obj = obj //var renderedMessage = messateTemplate.evaluate().getContent() //Whoo hoo! Scraped 11 milliseconds off this by using a literal string. var renderedMessage = eval('`'+ message +'`'); renderedMessage += "\n\n" var functionName = adminActionObj['Function Name'] //see moveToSheet Functions var emailTemplate = adminActionObj['Email Template'] var message = theSheet.getRange("C" + rowNum).getNote() obj['message'] = message // will be used in the emailTemplate obj['Status'] = status ///... override the data with what the menu item that was selected theSheet.getRange("C" + rowNum).setValue(status).setBackground(colour)//sigh! Have to do this cos of .moveRange() later if (status == "New" | status == '') { //Do nothing } else { switch (functionName) {//Choose which function to call case 'moveToCompletedSheetAndEmailUser': moveToCompletedSheetAndEmailUser(obj, emailTemplate, id)//rowNum is ignored now break; case "moveToCompletedSheetAndDontEmailUser": moveToCompletedSheetAndDontEmailUser(obj, emailTemplate, id) break; case "dontMoveAndEmailUser": dontMoveAndEmailUser(obj, emailTemplate, id) break; case "dontMoveAndEmailUserMultiple": dontMoveAndEmailUserMultiple(obj, emailTemplate, id) break; case "dontMoveAndDoNothing": Logger.log(`Doing nada: ${id}`) break; case "moveToAlma": moveToAlma(obj, emailTemplate, id) break; default: Logger.log("We shouldn't ever get here!") ss.toast("We shouldn't ever get here!") } } }//end if // //SpreadsheetApp.flush() /*if(DEBUG == true) { SpreadsheetApp.flush() lock.releaseLock(); Logger.log("Unlocked") }*/ ss.toast(renderedMessage)// and finally show SOMETHING! Logger.log("end handleAdminMenu: " + new Date()) return
} function doHandleFormSubmit() { var rowNum = ss.getActiveCell().getRow() handleFormSubmit(rowNum)
} function addIdToSelectedCell() { var currCell = ss.getActiveSheet().getActiveCell() currCell.setValue(Utilities.getUuid())
} function moveRowBackToToProcessSheet() { var currSheet = ss.getActiveSheet() var currRowNum = currSheet.getActiveCell().getRow() var currSheetName = currSheet.getName() var validSheets = ["Completed", "Alma Book Requests"]//"Alma Chapter Requests", "Alma Journal Requests" if (validSheets.includes(currSheetName) &amp; currRowNum &gt; 1) { //ss.toast("Moving row: " + currRowNum) var row = currSheet.getRange(currRowNum, 1, 1, currSheet.getLastColumn()).getValues()[0] row[2] = 'New' row[19] = Utilities.getUuid() // It's in T. Adds new ID number before moving back to To Process ss.getSheetByName("To Process").appendRow(row) //Do colouring var lastRow = toProcessSheet.getLastRow() toProcessSheet.getRange("C"+ lastRow).setBackground('lime')//hard-coded... don't slow it down. currSheet.deleteRow(currRowNum) ss.toast("Moved row: " + currRowNum + " to 'To Process' sheet complete") } } function fromMenuReDoBook() { var currSheet = ss.getActiveSheet() var currRowNum = currSheet.getActiveCell().getRow() var currSheetName = currSheet.getName() var validSheets = ["Alma Book Requests"]//"Alma Chapter Requests", "Alma Journal Requests" if (validSheets.includes(currSheetName) &amp; currRowNum &gt; 1) { ss.toast("Running doBook() " + currRowNum) currSheet.getRange("C" + currRowNum).setValue('New').setBackground("#ffffff") var row = currSheet.getRange(currRowNum, 1, 1, currSheet.getLastColumn()).getValues()[0] doBook(currRowNum, row) } }
function fromMenuReDoChapter() { var currSheet = ss.getActiveSheet() var currRowNum = currSheet.getActiveCell().getRow() var currSheetName = currSheet.getName() var validSheets = ["Alma Chapter Requests"]//"Alma Book Requests", "Alma Journal Requests" if (validSheets.includes(currSheetName) &amp; currRowNum &gt; 1) { ss.toast("Running doChapter() " + currRowNum) currSheet.getRange("C" + currRowNum).setValue('New').setBackground("#ffffff") var row = currSheet.getRange(currRowNum, 1, 1, currSheet.getLastColumn()).getValues()[0] doChapter(currRowNum, row) } } /////////////////////////////////////////////////////////////////// function fromMenuReDoJournal() { var currSheet = ss.getActiveSheet() var currRowNum = currSheet.getActiveCell().getRow() var currSheetName = currSheet.getName() var validSheets = ["Alma Journal Requests"]//"Alma Chapter Requests", "Alma Book Requests" if (validSheets.includes(currSheetName) &amp; currRowNum &gt; 1) { ss.toast("Running doJournal() " + currRowNum) currSheet.getRange("C" + currRowNum).setValue('New').setBackground("#ffffff") var row = currSheet.getRange(currRowNum, 1, 1, currSheet.getLastColumn()).getValues()[0] doJournal(currRowNum, row) } } ///////////////////// MISTAKENLY ENTERED AS BOOKS, BUT ACTUALLY CHAPTERS OR JOURNALS ////////////////// function moveFromToProcessToChapters(e) { var currSheet = ss.getActiveSheet() var destinationSheet = ss.getSheetByName("Alma Chapter Requests") var destinationSheetHeaders = destinationSheet.getRange(1, 1, 1, destinationSheet.getLastColumn()).getValues().flat() Logger.log(destinationSheetHeaders) var destinationSheetValues = [] var currRowNum = currSheet.getActiveCell().getRow() var currSheetName = currSheet.getName() var validSheets = ["To Process"] if (validSheets.includes(currSheetName) &amp; currRowNum &gt; 1) { //get the row var obj = objectifyOne(currSheet, currRowNum, 1) Logger.log(obj) //do the mapping for (header of destinationSheetHeaders) { switch (header) { case "ISBN": destinationSheetValues.push("") break; case "Chapter Title": destinationSheetValues.push("") break; case "Chapter Author": destinationSheetValues.push("") break; case "Page numbers": destinationSheetValues.push("") break; case "Volume": destinationSheetValues.push("") break; case "requestType": destinationSheetValues.push("Book Chapter") break; case "Copyright Declaration": destinationSheetValues.push("I have read the above statement and agree to abide by its restrictions.") break; case "Book Title": //where it needs Book Title, use the Item Title destinationSheetValues.push(obj["Item Title"]) break; case "Book Author": //where it needs Book Author, use the Author/Editor destinationSheetValues.push(obj["Author / Editor"]) break default: destinationSheetValues.push(obj[header]) } } for (i = 0; i &lt; destinationSheetHeaders.length; i++) { Logger.log(destinationSheetHeaders[i] + ": " + destinationSheetValues[i]) } //move to destination destinationSheet.appendRow(destinationSheetValues) // call API var destinationSheetRowNum = destinationSheet.getLastRow() destinationSheetValues[2] = "New" //Update status doChapter(destinationSheetRowNum, destinationSheetValues) //remove original //currSheet.deleteRow(currRowNum) var range = currSheet.getRange(currRowNum, 1, 1, currSheet.getLastRow()) range.setFontStyle("italic") currSheet.hideRow(range) } ss.toast("Moved to Alma Chapters")
} function moveFromToProcessToJournals(e) { var currSheet = ss.getActiveSheet() var destinationSheet = ss.getSheetByName("Alma Journal Requests") var destinationSheetHeaders = destinationSheet.getRange(1, 1, 1, destinationSheet.getLastColumn()).getValues().flat() Logger.log(destinationSheetHeaders) var destinationSheetValues = [] var currRowNum = currSheet.getActiveCell().getRow() var currSheetName = currSheet.getName() var validSheets = ["To Process"] if (validSheets.includes(currSheetName) &amp; currRowNum &gt; 1) { //get the row var obj = objectifyOne(currSheet, currRowNum, 1) Logger.log(obj) //do the mapping for (header of destinationSheetHeaders) { switch (header) { case "ISSN (1)": destinationSheetValues.push("") break; case "Journal Title (1)": destinationSheetValues.push("") break; case "Journal Volume (1)": destinationSheetValues.push("") break; case "Journal Issue or Part (1)": destinationSheetValues.push("") break; case "Pages (1)": destinationSheetValues.push("") break; case "Journal Year (1)": destinationSheetValues.push(obj["Publication Date or Edition"]) break; case "requestType": destinationSheetValues.push("Journal Article") break; case "Copyright Declaration (1)": destinationSheetValues.push("I have read the above statement and agree to abide by its restrictions.") break; case "Article Title (1)": //where it needs Book Title, use the Item Title destinationSheetValues.push(obj["Item Title"]) break; case "Article Author (1)": //where it needs Book Author, use the Author/Editor destinationSheetValues.push(obj["Author / Editor"]) break case "DOI (1)": //where it needs Book Author, use the Author/Editor destinationSheetValues.push(obj["url or source of reference"]) break case "Additional information (1)": //where it needs Book Author, use the Author/Editor destinationSheetValues.push(obj["Additional information"]) break default: destinationSheetValues.push(obj[header]) } } for (i = 0; i &lt; destinationSheetHeaders.length; i++) { Logger.log(destinationSheetHeaders[i] + ": " + destinationSheetValues[i]) } //move to destination destinationSheet.appendRow(destinationSheetValues) // call API var destinationSheetRowNum = destinationSheet.getLastRow() destinationSheetValues[2] = "New" //Update status... doJournal(destinationSheetRowNum, destinationSheetValues) //remove original //currSheet.deleteRow(currRowNum) var range = currSheet.getRange(currRowNum, 1, 1, currSheet.getLastRow()) range.setFontStyle("italic") currSheet.hideRow(range) } ss.toast("Moved to Alma Journals")
} //////////////// DO I NEED THIS? //////////////////////// function getAdminMenuItems() { //Get AdminActions // Could I move these into global scope so they're already loaded and loaded once, so quicker perhaps? // It does mean you can't add actions on-the-fly, or change messages etc... mmm? var adminActionRows = adminActionsSheet.getDataRange().getValues() var adminHeaders = adminActionRows.shift() var adminActionObjs = objectifyWithKey(adminHeaders, adminActionRows, "Dropdown Name") return adminActionObjs
} /////////////////////////// END Menu and Actions.gs /////////////////////////// /////////////////////////////// START doAlma.gs /////////////////////////////// // @ts-nocheck function doNewAlmaItems() { try { Logger.log("================ STARTING CHAPTERS ================") doChapters() Logger.log("================ STARTING BOOKS ================") doBooks() Logger.log("================ STARTING JOURNALS ================") doJournals() Logger.log("doAlma done!") } catch (e) { var error = e + " " + e.stack Logger.log(error) //MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error",error ) } } function doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, doi, add_info, email, userid, res_type, user, req_type) { Logger.log(`${title} by ${author} ${author1} ISBN: ${isbn} requested by ${email}`) orderItem(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, doi, add_info, email, userid, res_type, user, req_type)
} ////////////////////////////////// NOT USED FROM HERE DOWN - WE SHOULD DELETE ///////////////////////// function doChapters() {
/*chapter requests go straight to Alma/Rapid*/ var chaptersSheet = ss.getSheetByName("Alma Chapter Requests") //ensure that pages column is plain text chaptersSheet.getRange(2,10,1000,1000).setNumberFormat('@STRING@'); var values = chaptersSheet.getDataRange().getValues() var headers = values.shift() for (i = 0; i &lt; values.length; i++) { var row = values[i] var rowNum = i + 2 if (row[2] == 'New') { var res_type = 'BK' var req_type = '' var title = row[3] var author = row[4] var author1 = row[6] var chapter = row[5] var isbn = row[7] if (isbn == ''){ //update ISBN to dummy value and resubmit. This prevents item found in Alma errors //for blank isbns function randBetween(min, max) { var range = max - min + 1; var randomNum = Math.floor(Math.random() * range) + min; return randomNum; } isbn = isbn + randBetween(1,1000) +'xxx'; } var title1 = '' var issue = '' var pub_date = row[8] var publisher = row[9] var pages = row[10] var volume = row[11] var add_info = row[12] + ' ' + row[13] var email = row[15] var userid = row[16] //need to receive user's barcode before proceeding var user = findUser(userid); //check for error on findUser, i.e. invalid id var errStatus = PropertiesService.getScriptProperties().getProperty('errCode'); if(errStatus == '401861'){ PropertiesService.getScriptProperties().setProperty('errCode', 'none'); Logger.log('invalid userid') var range = "C" + rowNum; var cell = chaptersSheet.getRange(range); cell.setValue('INVALID USERID'); cell.setBackgroundRGB(255,0,0); }else{ //reset global property to handle errors PropertiesService.getScriptProperties().setProperty('errCode', 'none'); doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, add_info, email, userid, res_type, user, req_type); //do we have an error? var errStatus = PropertiesService.getScriptProperties().getProperty('errCode'); if (errStatus == '401604'){ //item found in Alma //update ISBN to dummy value and resubmit isbn = isbn + 'xxx'; PropertiesService.getScriptProperties().setProperty('errCode', 'none'); //MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error","Item found in inventory" ); doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, add_info, email, userid, res_type, user, req_type); //update status field at this stage to prevent repeated submissions var range = "C" + rowNum; var cell = chaptersSheet.getRange(range); cell.setValue('complete'); }else if (errStatus == '402362'){ //duplicate request //MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error","You already have a request for this item" ); PropertiesService.getScriptProperties().setProperty('errCode', 'none'); Logger.log('duplicate request') var range = "C" + rowNum; var cell = chaptersSheet.getRange(range); cell.setValue('DUPLICATE REQUEST'); cell.setBackgroundRGB(255,0,0); }else{ //update status field for completed requests var range = "C" + rowNum; var cell = chaptersSheet.getRange(range); cell.setValue('complete'); } } } } } function doBooks() { var booksSheet = ss.getSheetByName("Alma Book Requests") var values = booksSheet.getDataRange().getValues() var headers = values.shift() for (i = 0; i &lt; values.length; i++) { var row = values[i] var rowNum = i + 2 if (row[2] == 'Borrowed via ILL (sends email)' || row[2] == 'Thesis Requested (sends email)') { var res_type = 'BK' var req_type = 'Whole book' var title = row[3] var author = row[4] var author1 = '' var chapter = '' var isbn = row[5] if (isbn == ''){ //update ISBN to dummy value and resubmit. This prevents item found in Alma errors //for blank isbns function randBetween(min, max) { var range = max - min + 1; var randomNum = Math.floor(Math.random() * range) + min; return randomNum; } isbn = isbn + randBetween(1,1000) +'xxx'; } var title1 = '' var issue = '' var pub_date = row[6] var publisher = row[7] var pages = '' var volume = '' var add_info = row[8] + ' ' + row[11] var email = row[12] var userid = row[13] //need to receive user's barcode before proceeding var user = findUser(userid); //check for error on findUser, i.e. invalid id var errStatus = PropertiesService.getScriptProperties().getProperty('errCode'); if(errStatus == '401861'){ PropertiesService.getScriptProperties().setProperty('errCode', 'none'); Logger.log('invalid userid') var range = "C" + rowNum; var cell = booksSheet.getRange(range); cell.setValue('INVALID USERID'); cell.setBackgroundRGB(255,0,0); }else{ //reset global property to handle errors PropertiesService.getScriptProperties().setProperty('errCode', 'none'); doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, add_info, email, userid, res_type, user, req_type); //do we have an error? var errStatus = PropertiesService.getScriptProperties().getProperty('errCode'); if (errStatus == '401604'){ //item found in Alma //update ISBN to dummy value and resubmit isbn = isbn + 'xxx'; PropertiesService.getScriptProperties().setProperty('errCode', 'none'); //MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error","Item found in inventory" ); doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, add_info, email, userid, res_type, user, req_type); //update status field at this stage to prevent repeated submissions var range = "C" + rowNum; var cell = booksSheet.getRange(range); cell.setValue('complete'); }else if (errStatus == '402362'){ //duplicate request //MailApp.sendEmail({to: email, subject: "Tell Us What You Need Error", body: "You already have a request for this item"}); PropertiesService.getScriptProperties().setProperty('errCode', 'none'); Logger.log('duplicate request') var range = "C" + rowNum; var cell = booksSheet.getRange(range); cell.setValue('DUPLICATE REQUEST'); cell.setBackgroundRGB(255,0,0); }else{ //update status field for completed requests var range = "C" + rowNum; var cell = booksSheet.getRange(range); cell.setValue('complete'); //MailApp.sendEmail({to: email, subject: "Thanks for your request", body: "The Interlending Team have received your request and will be in touch soon."}); } } } }
} function doJournals() { /*article requests go straight to Alma/Rapid*/ var journalsSheet = ss.getSheetByName("Alma Journal Requests") //ensure that pages column is plain text journalsSheet.getRange(2,10,1000,1000).setNumberFormat('@STRING@'); var values = journalsSheet.getDataRange().getValues() var headers = values.shift() for (i = 0; i &lt; values.length; i++) { var row = values[i] var rowNum = i + 2 if (row[2] == 'New') { var res_type = 'CR' var req_type = '' var title = row[3] //journal title var title1 = row[4] //article title var author1 = '' var chapter = '' var publisher = '' var author = row[5] //article author var isbn = row[6] if (isbn == ''){ //update ISBN to dummy value and resubmit. This prevents item found in Alma errors //for blank isbns function randBetween(min, max) { var range = max - min + 1; var randomNum = Math.floor(Math.random() * range) + min; return randomNum; } isbn = isbn + randBetween(1,1000) +'xxx'; } var volume = row[7] var issue = row [8] var userid = row[15] var issue = row[8] var pub_date = row[9] var email = row[14] var username = row[15] var pages = row[10] var add_info = row[11] + ' ' + row[12] //reset global property to handle errors PropertiesService.getScriptProperties().setProperty('errCode', 'none'); //need to retrieve user's barcode before proceeding var user = findUser(userid); //check for error on findUser, i.e. invalid id var errStatus = PropertiesService.getScriptProperties().getProperty('errCode'); if(errStatus == '401861'){ PropertiesService.getScriptProperties().setProperty('errCode', 'none'); Logger.log('invalid userid') var range = "C" + rowNum; var cell = journalsSheet.getRange(range); cell.setValue('INVALID USERID'); cell.setBackgroundRGB(255,0,0); }else{ // now we can go ahead with the sharing request doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, add_info, email, userid, res_type, user, req_type) //do we have an error? var errStatus = PropertiesService.getScriptProperties().getProperty('errCode'); if (errStatus == '401604'){ //item found in Alma PropertiesService.getScriptProperties().setProperty('errCode', 'none'); //MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error","Item found in inventory" ); isbn = isbn + 'xxx' doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, add_info, email, userid, res_type, user, req_type); //update status field for completed requests var range = "C" + rowNum; var cell = journalsSheet.getRange(range); cell.setValue('complete'); }else if (errStatus == '402362'){ //duplicate request //MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error","You already have a request for this item" ); PropertiesService.getScriptProperties().setProperty('errCode', 'none'); Logger.log('duplicate request') var range = "C" + rowNum; var cell = journalsSheet.getRange(range); cell.setValue('DUPLICATE REQUEST'); cell.setBackgroundRGB(255,0,0); }else{ //update status field for completed requests var range = "C" + rowNum; var cell = journalsSheet.getRange(range); cell.setValue('complete'); } } } }
} //////////////////////////////// END doAlma.gs //////////////////////////////// /////////////////////////////// START doAlma2.gs /////////////////////////////// // onFormSubmit handles, Chapters and Articles... automatically goes to ALMA
// When somebody selects "Borrowed by ILL", "Thesis Requested"... handles "Whole Books or Thesis" handles Book Requests function doChapter(rowNum, row) { /*chapter requests go straight to Alma/Rapid*/ var chaptersSheet = ss.getSheetByName("Alma Chapter Requests") chaptersSheet.getRange(2, 10, 1000, 1000).setNumberFormat('@STRING@'); Logger.log("doChapter: " + rowNum + " " + row) var booksSheet = ss.getSheetByName("Alma Chapter Requests") // Logger.log("row[2]" + row[2]) var res_type = 'BK' var req_type = '' var title = row[3] var author = row[4] var author1 = row[6] var chapter = row[5] var isbn = row[7] if (isbn == '') { //update ISBN to dummy value and resubmit. This prevents item found in Alma errors //for blank isbns function randBetween(min, max) { var range = max - min + 1; var randomNum = Math.floor(Math.random() * range) + min; return randomNum; } isbn = isbn + randBetween(1, 1000) + 'xxx'; } var title1 = '' var issue = '' var doi = '' var pub_date = row[8] var publisher = row[9] var pages = row[10] var volume = row[11] var add_info = row[12] + ' ' + row[13] var email = row[15] var userid = row[16] //need to receive user's barcode before proceeding var user = findUser(userid); //check for error on findUser, i.e. invalid id var errStatus = PropertiesService.getScriptProperties().getProperty('errCode'); if (errStatus == '401861') { PropertiesService.getScriptProperties().setProperty('errCode', 'none'); Logger.log('invalid userid') MailApp.sendEmail(email, "Tell Us What You Need Error", "You may have entered your username incorrectly on your recent request. Library staff will check this and get in touch with you if there are any issues. There is no need for you to contact us.", {noReply:true}); var range = "C" + rowNum; var cell = chaptersSheet.getRange(range); cell.setValue('INVALID USERID'); cell.setBackgroundRGB(255, 0, 0); MailApp.sendEmail("libr569@york.ac.uk,", "ACTION REQUIRED: Tell Us What You Need Error", "Dear OPIL. An invalid user id has been entered on the CHAPTERS tab. Please check and correct it and then resend the request to Alma.", {noReply:true}); } else { //reset global property to handle errors PropertiesService.getScriptProperties().setProperty('errCode', 'none'); doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, doi, add_info, email, userid, res_type, user, req_type); //do we have an error? var errStatus = PropertiesService.getScriptProperties().getProperty('errCode'); if (errStatus == '401604') { //item found in Alma //update ISBN to dummy value and resubmit isbn = isbn + 'xxx'; PropertiesService.getScriptProperties().setProperty('errCode', 'none'); //MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error","Item found in inventory" ); doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, doi, add_info, email, userid, res_type, user, req_type); //update status field at this stage to prevent repeated submissions var range = "C" + rowNum; var cell = chaptersSheet.getRange(range); cell.setValue('complete'); } else if (errStatus == '402362') { //duplicate request //MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error","You already have a request for this item" ); PropertiesService.getScriptProperties().setProperty('errCode', 'none'); Logger.log('duplicate request') var range = "C" + rowNum; var cell = chaptersSheet.getRange(range); cell.setValue('DUPLICATE REQUEST'); cell.setBackgroundRGB(255, 0, 0); } else { //update status field for completed requests var range = "C" + rowNum; var cell = chaptersSheet.getRange(range); cell.setValue('complete'); } } } Logger.log("Completed!") function test_doBook() { var rowNum = 364 var booksSheet = ss.getSheetByName("Alma Book Requests") var row = booksSheet.getRange(rowNum, 1, 1, booksSheet.getLastColumn()).getValues()[0] doBook(rowNum, row)
}
function doBook(rowNum, row) { Logger.log("doBook: " + rowNum + " " + row) var booksSheet = ss.getSheetByName("Alma Book Requests") //Logger.log("row[2]" + row[2]) //'Borrowed via ILL (sends email)' || row[2] == 'Thesis Requested (sends email)' //Logger.log("Book Request: " + row[3]) var res_type = 'BK' var req_type = 'Whole book' var title = row[3] var author = row[4] var author1 = '' var chapter = '' var isbn = row[5] if (isbn == '') { //update ISBN to dummy value and resubmit. This prevents item found in Alma errors //for blank isbns function randBetween(min, max) { var range = max - min + 1; var randomNum = Math.floor(Math.random() * range) + min; return randomNum; } isbn = isbn + randBetween(1, 1000) + 'xxx'; } var title1 = '' var issue = '' var pub_date = row[6] var publisher = row[7] var pages = '' var volume = '' var doi = '' var add_info = row[8] + ' ' + row[11] var email = row[12] var userid = row[13] //need to receive user's barcode before proceeding var user = findUser(userid); //check for error on findUser, i.e. invalid id var errStatus = PropertiesService.getScriptProperties().getProperty('errCode'); if (errStatus == '401861') { PropertiesService.getScriptProperties().setProperty('errCode', 'none'); Logger.log('invalid userid'); MailApp.sendEmail(email, "Tell Us What You Need Error", "You may have entered your username incorrectly on your recent request. Library staff will check this and get in touch with you if there are any issues. There is no need for you to contact us.",{noReply:true}); var range = "C" + rowNum; var cell = booksSheet.getRange(range); cell.setValue('INVALID USERID'); cell.setBackgroundRGB(255, 0, 0); MailApp.sendEmail("libr569@york.ac.uk,", "Tell Us What You Need Error", "Dear OPIL. An invalid userid has been entered on the BOOKS tab. Please correct it and resend it to Alma.", {noReply:true}); } else { //reset global property to handle errors PropertiesService.getScriptProperties().setProperty('errCode', 'none'); doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, doi, add_info, email, userid, res_type, user, req_type); //do we have an error? var errStatus = PropertiesService.getScriptProperties().getProperty('errCode'); if (errStatus == '401604') { //item found in Alma //update ISBN to dummy value and resubmit isbn = isbn + 'xxx'; PropertiesService.getScriptProperties().setProperty('errCode', 'none'); //MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error","Item found in inventory" ); doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, doi, add_info, email, userid, res_type, user, req_type); //update status field at this stage to prevent repeated submissions var range = "C" + rowNum; var cell = booksSheet.getRange(range); cell.setValue('complete'); } else if (errStatus == '402362') { //duplicate request //MailApp.sendEmail({to: email, subject: "Tell Us What You Need Error", body: "You already have a request for this item"}); PropertiesService.getScriptProperties().setProperty('errCode', 'none'); Logger.log('duplicate request') var range = "C" + rowNum; var cell = booksSheet.getRange(range); cell.setValue('DUPLICATE REQUEST'); cell.setBackgroundRGB(255, 0, 0); } else { //update status field for completed requests var range = "C" + rowNum; var cell = booksSheet.getRange(range); cell.setValue('complete'); //MailApp.sendEmail({to: email, subject: "Thanks for your request", body: "The Interlending Team have received your request and will be in touch soon."}); } }
} function test_doJournal() { var rowNum = 13 var journalSheet = ss.getSheetByName("Alma Journal Requests") var row = journalSheet.getRange(rowNum, 1, 1, journalSheet.getLastColumn()).getValues()[0] doJournal(rowNum, row)
} function doJournal(rowNum, row) { /*article requests go straight to Alma/Rapid*/ var journalsSheet = ss.getSheetByName("Alma Journal Requests") //ensure that pages column is plain text journalsSheet.getRange(2, 10, 1000, 1000).setNumberFormat('@STRING@'); Logger.log("doJournal: " + rowNum + " " + row) var booksSheet = ss.getSheetByName("Alma Journal Requests") Logger.log("row[2]" + row[2]) var res_type = 'CR' var req_type = '' var title = row[3] //journal title var title1 = row[4] //article title var author1 = '' var chapter = '' var publisher = '' var author = row[5] //article author var isbn = row[6] if (isbn == '') { //update ISBN to dummy value and resubmit. This prevents item found in Alma errors //for blank isbns function randBetween(min, max) { var range = max - min + 1; var randomNum = Math.floor(Math.random() * range) + min; return randomNum; } isbn = isbn + randBetween(1, 1000) + 'xxx'; } var volume = row[7] var issue = row[8] var userid = row[15] var pub_date = row[9] var email = row[14] var pages = row[10] var doi = row[11] //remove url prefix from doi if present if (doi.startsWith("https://doi.org/")) { doi = doi.substring(16);} else if (doi.startsWith("https://")) { doi = doi.substring(8); // Remove "https://" }else if (doi.startsWith("http://")) { doi = doi.substring(7); // Remove "http://" }else if (doi.startsWith ("doi:")){ doi = doi.substring(4);} // Remove "doi" // Remove www. if needed if (doi.startsWith("www.")) { doi = doi.substring(4); } var add_info = row[12] //this bit to change re DOI? KW //reset global property to handle errors PropertiesService.getScriptProperties().setProperty('errCode', 'none'); //need to retrieve user's barcode before proceeding var user = findUser(userid); //check for error on findUser, i.e. invalid id var errStatus = PropertiesService.getScriptProperties().getProperty('errCode'); if (errStatus == '401861') { PropertiesService.getScriptProperties().setProperty('errCode', 'none'); Logger.log('invalid userid'); MailApp.sendEmail(email, "Tell Us What You Need Error", "You may have entered your username incorrectly on your recent request. Library staff will check this and get in touch with you if there are any issues. There is no need for you to contact us.", {noReply:true}); var range = "C" + rowNum; var cell = journalsSheet.getRange(range); cell.setValue('INVALID USERID'); cell.setBackgroundRGB(255, 0, 0); MailApp.sendEmail("libr569@york.ac.uk,", "ACTION REQUIRED: Tell Us What You Need Error", "Dear OPIL. An invalid user id has been entered on the JOURNALS tab. Please check and correct it then resend the request to Alma.", {noReply:true}); } else { // now we can go ahead with the sharing request doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, doi, add_info, email, userid, res_type, user, req_type) //do we have an error? var errStatus = PropertiesService.getScriptProperties().getProperty('errCode'); if (errStatus == '401604') { //item found in Alma PropertiesService.getScriptProperties().setProperty('errCode', 'none'); //MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error","Item found in inventory" ); isbn = isbn + 'xxx' doAPI(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, doi, add_info, email, userid, res_type, user, req_type); //update status field for completed requests var range = "C" + rowNum; var cell = journalsSheet.getRange(range); cell.setValue('complete'); } else if (errStatus == '402362') { //duplicate request //MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error","You already have a request for this item" ); PropertiesService.getScriptProperties().setProperty('errCode', 'none'); Logger.log('duplicate request') var range = "C" + rowNum; var cell = journalsSheet.getRange(range); cell.setValue('DUPLICATE REQUEST'); cell.setBackgroundRGB(255, 0, 0); } else { //update status field for completed requests var range = "C" + rowNum; var cell = journalsSheet.getRange(range); cell.setValue('complete'); } } } //////////////////////////////// END doAlma2.gs //////////////////////////////// /////////////////////////////// START getUser.gs /////////////////////////////// function findUser(userid) { /* retrieve user based on user id */ /*/almaws/v1/users*/ let baseUrl = "https://api-eu.hosted.exlibrisgroup.com/almaws/v1/users/" + userid + "?user_id_type=all_unique&amp;view=full&amp;expand=none&amp;apikey=l8xxd50b2b9149ad41e8b64e8eadaef2381c"; var headers = { "Content-Type": "application/xml", "Accept": "application/xml", }; var options = { "headers": headers, "method": "GET", "muteHttpExceptions": true, }; //retrieve barcode var response = UrlFetchApp.fetch(baseUrl,options); var status = response.getResponseCode(); Logger.log( "getUser status: " + status); var result = response.getContentText(); if (status == 200){ var responses = XmlService.parse(response); var id_types = new Array; var root = responses.getRootElement(); id_types = root.getChild('user_identifiers').getChildren('user_identifier'); //iterate through array of identifiers to find barcode for (var i = 0; i &lt; id_types.length; i++) { var id_typ; id_typ = id_types[i].getChild('id_type').getText(); if (id_typ == ('01')) { var bcode = id_types[i].getChild('value').getText(); return (bcode); } } }else{ var errorResult = XmlService.parse(response); var root = errorResult.getRootElement(); var err= root.getChildren(); var flag = err[1].getChildren(); // &lt;error&gt; var errDets = flag[0].getChildren(); //&lt;error&gt; children errCode = errDets[0].getText(); //errorCode if (errCode == '401861')//user id not found{ MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error",'Invalid user ID entered on form' ); PropertiesService.getScriptProperties().setProperty('errCode', errCode); }
} //////////////////////////////// END getUser.gs //////////////////////////////// /////////////////////////// START sharingRequest.gs /////////////////////////// function orderItem(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, doi, add_info, email, userid, res_type, user, req_type) { //base URL for the request, takes the below payload
let baseUrl = "https://api-eu.hosted.exlibrisgroup.com/almaws/v1/users/" + userid + "/resource-sharing-requests?override_blocks=false&amp;apikey=l8xxd50b2b9149ad41e8b64e8eadaef2381c"; var payload = createXml(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, doi, add_info, email, userid, res_type, user, req_type); var headers = { "Content-Type": "application/xml", "Accept":"application/xml", }; var options = { "headers": headers, "method" : "POST", "muteHttpExceptions": true, "payload": payload }; var response = UrlFetchApp.fetch(baseUrl,options); var status = response.getResponseCode(); Logger.log( "sharingRequest status: " + status); var result = response.getContentText(); if (status == 200){ Logger.log ( result ); }else{ var errorResult = XmlService.parse(response); Logger.log(errorResult); var root = errorResult.getRootElement(); var err= root.getChildren(); var flag = err[1].getChildren(); // &lt;error&gt; var errDets = flag[0].getChildren(); //&lt;error&gt; children var errCode = errDets[0].getText(); //errorCode PropertiesService.getScriptProperties().setProperty('errCode', errCode); } } //////////////////////////// END sharingRequest.gs //////////////////////////// //////////////////////////// START createPayload.gs //////////////////////////// //create resource sharing object payload //see https://developers.exlibrisgroup.com/alma/apis/docs/xsd/rest_user_resource_sharing_request.xsd/?tags=POST function createXml(title, author, chapter, title1, author1, isbn, pub_date, publisher, pages, volume, issue, doi, add_info, email, userid, res_type, user, req_type){ let root = XmlService.createElement('user_resource_sharing_request'); let usrNode = XmlService.createElement('requester').setText(userid); let titleNode = XmlService.createElement('title'); if (res_type == 'CR'){ titleNode.setText(title1); }else{ titleNode.setText(title); } let authorNode = XmlService.createElement('author').setText(author); let chapterNode = XmlService.createElement('chapter_title').setText(chapter); let title1Node = XmlService.createElement('journal_title').setText(title); let author1Node = XmlService.createElement('chapter_author').setText(author1); let volumeNode = XmlService.createElement('volume').setText(volume); let issueNode = XmlService.createElement('issue').setText(issue); let doiNode = XmlService.createElement('doi').setText(doi); let infoNode = XmlService.createElement('note').setText(add_info); let isbnNode = XmlService.createElement('isbn').setText(isbn); let issnNode = XmlService.createElement('issn').setText(isbn); let yearNode = XmlService.createElement('year').setText(pub_date); let publisherNode = XmlService.createElement('publisher').setText(publisher); let pagesNode = XmlService.createElement('pages').setText(pages); if (req_type == 'Whole book'){ formatNode = XmlService.createElement('format').setText('PHYSICAL'); }else{ formatNode = XmlService.createElement('format').setText('DIGITAL'); } let pickNode = XmlService.createElement('pickup_location').setText('JBM'); let copyNode = XmlService.createElement('agree_to_copyright_terms').setText('true'); //BK/CR book/article let typeNode = XmlService.createElement('citation_type').setText(res_type); let document = XmlService.createDocument(root); root.addContent(titleNode); root.addContent(authorNode); root.addContent(usrNode); root.addContent(chapterNode); root.addContent(title1Node); root.addContent(author1Node); root.addContent(pagesNode); root.addContent(volumeNode); root.addContent(infoNode); root.addContent(formatNode); root.addContent(typeNode); root.addContent(issueNode); root.addContent(doiNode); root.addContent(pickNode); root.addContent(isbnNode); root.addContent(issnNode); root.addContent(yearNode); root.addContent(publisherNode); root.addContent(copyNode); let payload = XmlService.getPrettyFormat().format(document); Logger.log("Payload is " + payload); return(payload); } ///////////////////////////// END createPayload.gs ///////////////////////////// ////////////////////////////// START getItems.gs ////////////////////////////// //Used by the code below, to get all the repeated Form questions into Arrays
function getItem(listOfColumnNames, obj){ var result = [] for (columnName of listOfColumnNames){ if (obj[columnName] == ''){ }else{ Logger.log('columnName:' + columnName + ": " + obj[columnName] ) } result.push(obj[columnName]) } return result
} // This is gonna be easier to do "by hand" than with fancy code ...cos, changes later...
function getItems(obj){ var results = [] // So if you use tab delimiters to define this, you can just copy-n-paste the header row from the spreadsheet (much quicker) var item1ColumnNames = `Item Title (1)	Author / Editor (1)	ISBN (1)	Publication Date or Edition (1)	Publisher (1)	url or source of reference (1)	Method of supply (1)	Supply time (1)	Additional information (1)`.split("\t") var item1 = getItem(item1ColumnNames, obj) if (item1[0]!= ''){//checks that the title isn't empty (bit risky, should be fine) results.push(item1) } var item2ColumnNames =`Item Title (2)	Author / Editor (2)	ISBN (2)	Publication Date or Edition (2)	Publisher (2)	url or source of reference (2)	Method of supply (2)	Supply time (2)	Additional information (2)`.split("\t") var item2 = getItem(item2ColumnNames, obj) if (item2[0]!= ''){ results.push(item2) } var item3ColumnNames = `Item Title (3)	Author / Editor (3)	ISBN (3)	Publication Date or Edition (3)	Publisher (3)	url or source of reference (3)	Method of supply (3)	Supply time (3)	Additional information (3)`.split("\t") var item3 = getItem(item3ColumnNames, obj) if (item3[0]!= ''){ results.push(item3) } var item4ColumnNames = `Item Title (4)	Author / Editor (4)	ISBN (4)	Publication Date or Edition (4)	Publisher (4)	url or source of reference (4)	Method of supply (4)	Supply time (4)	Additional information (4)`.split("\t") var item4 = getItem(item4ColumnNames, obj) if (item4[0]!= ''){ results.push(item4) } var item5ColumnNames = `Item Title (5)	Author / Editor (5)	ISBN (5)	Publication Date or Edition (5)	Publisher (5)	url or source of reference (5)	Method of supply (5)	Supply time (5)	Additional information (5)`.split("\t") var item5 = getItem(item5ColumnNames, obj) if (item5[0]!= ''){ results.push(item5) } var item6ColumnNames = `Item Title (6)	Author / Editor (6)	ISBN (6)	Publication Date or Edition (6)	Publisher (6)	url or source of reference (6)	Method of supply (6)	Supply time (6)	Additional information (6)`.split("\t") var item6 = getItem(item6ColumnNames, obj) if (item6[0]!= ''){ results.push(item6) } var item7ColumnNames = `Item Title (7)	Author / Editor (7)	ISBN (7)	Publication Date or Edition (7)	Publisher (7)	url or source of reference (7)	Method of supply (7)	Supply time (7)	Additional information (7)`.split("\t") var item7 = getItem(item7ColumnNames, obj) if (item7[0]!= ''){ results.push(item7) } return results
} /////////////////////////////// END getItems.gs /////////////////////////////// ///////////////////////////// START getChapters.gs ///////////////////////////// function getChapters(obj) { var results = [] // So if you use tab delimiters to define this, you can just copy-n-paste the header row from the spreadsheet (much quicker) var chapterColumnNames = `Book Title	Book Author	Chapter Title	Chapter Author	ISBN	Publication Date or Edition	Publisher	Page numbers	Volume	url or source of reference	Additional information	Copyright Declaration`.split("\t") var chapter = getItem(chapterColumnNames, obj) //Note only one chapter to do. if (chapter[0]!= ''){ results.push(chapter) } return results//returns a list of chapters for consistency with similar actions
} ////////////////////////////// END getChapters.gs ////////////////////////////// ///////////////////////////// START getJournals.gs ///////////////////////////// function getJournals(obj) { var results = [] // So if you use tab delimiters to define this, you can just copy-n-paste the header row from the spreadsheet (much quicker) var journal1ColumnNames = `Journal Title (1)	Article Title (1)	Article Author (1)	ISSN (1)	Journal Volume (1)	Journal Issue or Part (1)	Journal Year (1)	Pages (1)	DOI (1)	Any further information (1)	Copyright Declaration (1)`.split("\t") var journal1 = getItem(journal1ColumnNames, obj) Logger.log(journal1) if (journal1[0]!= ''){ results.push(journal1) } var journal2ColumnNames = `Journal Title (2)	Article Title (2)	Article Author (2)	ISSN (2)	Journal Volume (2)	Journal Issue or Part (2)	Journal Year (2)	Pages (2)	DOI (2)	Any further information (2)	Copyright Declaration (2)`.split("\t") var journal2 = getItem(journal2ColumnNames, obj) if (journal2[0]!= ''){ results.push(journal2) } var journal3ColumnNames = `Journal Title (3)	Article Title (3)	Article Author (3)	ISSN (3)	Journal Volume (3)	Journal Issue or Part (3)	Journal Year (3)	Pages (3)	DOI (3)	Any further information (3)	Copyright Declaration (3)`.split("\t") var journal3 = getItem(journal3ColumnNames, obj) if (journal3[0]!= ''){ results.push(journal3) } var journal4ColumnNames = `Journal Title (4)	Article Title (4)	Article Author (4)	ISSN (4)	Journal Volume (4)	Journal Issue or Part (4)	Journal Year (4)	Pages (4)	DOI (4)	Any further information (4)	Copyright Declaration (4)`.split("\t") var journal4 = getItem(journal4ColumnNames, obj) if (journal4[0]!= ''){ results.push(journal4) } var journal5ColumnNames = `Journal Title (5)	Article Title (5)	Article Author (5)	ISSN (5)	Journal Volume (5)	Journal Issue or Part (5)	Journal Year (5)	Pages (5)	DOI (5)	Any further information (5)	Copyright Declaration (5)`.split("\t") var journal5 = getItem(journal5ColumnNames, obj) if (journal5[0]!= ''){ results.push(journal5) } var journal6ColumnNames = `Journal Title (6)	Article Title (6)	Article Author (6)	ISSN (6)	Journal Volume (6)	Journal Issue or Part (6)	Journal Year (6)	Pages (6)	DOI (6)	Any further information (6)	Copyright Declaration (6)`.split("\t") var journal6 = getItem(journal6ColumnNames, obj) if (journal6[0]!= ''){ results.push(journal6) } var journal7ColumnNames = `Journal Title (7)	Article Title (7)	Article Author (7)	ISSN (7)	Journal Volume (7)	Journal Issue or Part (7)	Journal Year (7)	Pages (7)	DOI (7)	Any further information (7)	Copyright Declaration (7)`.split("\t") var journal7 = getItem(journal7ColumnNames, obj) if (journal7[0]!= ''){ results.push(journal7) } return results
} ////////////////////////////// END getJournals.gs ////////////////////////////// ///////////////////////////// START getRowById.gs ///////////////////////////// ////// UTILITY BITS
function test_getRowById() { id = "cbaf85b8-5e74-41f0-8ec6-dac1a695e9e5" // copy this from a sheet. //Method 3: TextFinder var rangeStr = "T2:T" + toProcessSheet.getLastRow() var startTime = new Date().getTime() Logger.log("TextFinder rowNum: " + getRowNumById(id, rangeStr)) var endTime = new Date().getTime() Logger.log("Duration: " + (endTime - startTime) + "") /// method 1: Iteration var startTime = new Date().getTime() Logger.log("Iteration rowNum: " + getRowNumById3(id)) var endTime = new Date().getTime() Logger.log("Duration: " + (endTime - startTime) + "") } //The old way... by iteration
function getRowNumById3(id) { var lastRow = toProcessSheet.getLastRow() var values = toProcessSheet.getRange("T2:" + lastRow).getValues() for (i = 0; i &lt; values.length; i++) { if (values[i][0] == id) { return Number(i) + 2 } } Logger.log("Error with getRowNumById: " + id)
} ////////////////// TEXTFINDER ////////////////
function getRowNumById(id){ var found = toProcessSheet.getRange("T2:T").createTextFinder(id).findNext(); //harcoded return found.getRow() var result = found.getA1Notation() //result = result.replace("T", "")//hack //return result
}
//////////////// END TEXTFINDER ////////////// ////////////////////////////// END getRowById.gs ////////////////////////////// /////////////////////// START move To Sheet Functions.gs /////////////////////// var userColumn = "U" //where "who edited this" is added"
var whenTimestampColumn = "V" //where "when this was edited" is added /*
Each row now automatically gets a UniqueId when it is created... so that when two people are working on the To Process sheet, The situation where person A has opened a dialog to move row X and not yet completed, and they person B, opens a dialog to move row Y and completes (that is above row X, altering row X's rowNum), resulting in Person A's action to perform on the wrong row. What happpens now is that when Person A clicks on "OK", the id is re-looked up, and found agaib, and it's new rowNum is used, not the one when the Person A started the process. The id is only used to workaround this "multiple people working at the same time" issue, but may well be useful later on if we need to somehow address individual rows. The function below is just to prime our existing examples and should not really ever be run again now. function generateFakeIdsForProcessSheet(){ var toProcessSheet = ss.getSheetByName("To Process") var lastRow = toProcessSheet.getLastRow() var ids = [] for (i=2; i&lt;=lastRow ; i++){ ids.push( [Utilities.getUuid()] )//note each item has to be a list } Logger.log(toProcessSheet.getRange("T2:T" + lastRow).getA1Notation() ) Logger.log( ids.length) Logger.log(ids) toProcessSheet.getRange("T2:T" + lastRow).setValues(ids) }*/ //Used by all the functions below to move row:RowNum from sourceSheet to destinationSheet function hideProcessRow(rowNum){ toProcessSheet.hideRow(rowNum)
} var style = SpreadsheetApp.newTextStyle().setItalic(true).build() function moveToSheet(rowNum, sourceSheet, destinationSheet) { //Note: This moves the row, not the row's data //move keeps styling, notes etc. var user = Session.getActiveUser().getEmail() var whenThisHappened = new Date() //First annotate the row with who did it and when in userColumn and whenTimestampColumn sourceSheet.getRange(userColumn + rowNum +":" + whenTimestampColumn + rowNum).setValues([[user,whenThisHappened]])//whoo hoo! one hit instead of two var colWidth = sourceSheet.getLastColumn() var selectedRange = sourceSheet.getRange(rowNum, 1, 1, colWidth) var lastRowPlusOne = destinationSheet.getLastRow() + 1 // Need to make sure there is room. Laters Logger.log(`lastRowPlusOne: ${lastRowPlusOne}`) selectedRange.setTextStyle(style)//Do style changes Logger.log(selectedRange.getA1Notation()) selectedRange.copyTo(destinationSheet.getRange(lastRowPlusOne, 1, 1, colWidth)) sourceSheet.hideRow(selectedRange) /// HIDE THE ROW!!!! return
} function fixAllStatusColours(){ /* var values = toProcessSheet.getRange("C2:C").getValues() for(i=0; i&lt;values.length; i++){ var row = values[i] var rowNum = i+2 var status = row[0] var adminActionObj = adminActionObjs[status] var colour = adminActionObj['Colour'] Logger.log(`${rowNum} $(status): ${colour}`) toProcessSheet.getRange("C" + rowNum).setBackground(colour) }*/ var values = adminActionsSheet.getRange("F2:F").getValues() for(i=0; i&lt;values.length; i++){ var row = values[i] var rowNum = i+2 var colour = row[0] adminActionsSheet.getRange("F" + rowNum).setBackground(colour) }
} function EmailSender(obj, email, subject, templateName) { subject = subject.slice(0, 69).toString() // trim if too long... var emailTemplate = HtmlService.createTemplateFromFile(templateName) emailTemplate.obj = obj var renderedEmailMessage = emailTemplate.evaluate().getContent().toString() MailApp.sendEmail(email, subject, '', { bcc: 'lib-orders@york.ac.uk', noReply: true, htmlBody: renderedEmailMessage }) } //////////////////////////////// MOVE TO SHEET FUNCTIONS ////////////////////////////////// function moveToCompletedSheetAndEmailUser(obj, templateName, id) { var rowNum = getRowNumById(id) var destinationSheet = ss.getSheetByName("Completed") moveToSheet(rowNum, toProcessSheet, destinationSheet) //Get email template and render var email = obj['email'] var subject = "Library request update: " + obj["Item Title"] EmailSender(obj, email, subject, templateName) } function moveToCompletedSheetAndDontEmailUser(obj, templateName, id) { var rowNum = getRowNumById(id) var destinationSheet = ss.getSheetByName("Completed") moveToSheet(rowNum, toProcessSheet, destinationSheet) } function dontMoveAndEmailUser(obj, templateName, id) { //Logger.log("Just emailing: " + obj['email']) //Send Email var email = obj['email'] var subject = "Library request update: " + obj["Item Title"] EmailSender(obj, email, subject, templateName) } function dontMoveAndEmailUserMultiple(obj, templateName, id) { //Logger.log("Just emailing: " + obj['email']) //Send Email var email = obj['email'] var subject = "Query about recent Library requests (Tell Us What You Need)" EmailSender(obj, email, subject, templateName) } function moveToAlma(obj, templateName, id) { obj['Status'] = 'New' if (obj['requestType'] == 'Book Chapter') { moveToAlmaChapterRequestsAndEmailUser(obj, templateName, id) } else if (obj['requestType'] == 'Journal Article') { moveToAlmaBookRequestsAndEmailUser(obj, templateName, id) } else if (obj['requestType'] == 'Whole Book or Thesis') { moveToAlmaBookRequestsAndEmailUser(obj, templateName, id) } else { Logger.log("Error with moveToAlma: " + JSON.stringify(obj)) } } function moveToAlmaChapterRequestsAndEmailUser(obj, templateName, id) { //Lock interface? var destinationSheet = ss.getSheetByName("Alma Chapter Requests") //Logger.log("Moving to " + destinationSheet.getName()) var rowNum = getRowNumById(id) moveToSheet(rowNum, toProcessSheet, destinationSheet) destinationSheet.getRange(rowNum, 3).setValue("New")//hack var email = obj['email'] var subject = "Library request update: " + obj["Item Title"] EmailSender(obj, email, subject, templateName) } function moveToAlmaBookRequestsAndEmailUser(obj, templateName, id) { var destinationSheet = ss.getSheetByName("Alma Book Requests") var rowNum = getRowNumById(id) moveToSheet(rowNum, toProcessSheet, destinationSheet) var email = obj['email'] var subject = "Library request update: " + obj["Item Title"] EmailSender(obj, email, subject, templateName) //Email has been sent, call the API var theNewRowNum = destinationSheet.getLastRow()//the one we just added var row = destinationSheet.getRange(theNewRowNum, 1, 1, destinationSheet.getLastColumn()).getValues()[0]//get one row row[2] = 'New' Logger.log("Setting status to:" + row[2]) Logger.log("Calling doBook from moveToAlmaBookRequestsAndEmailUser: " + theNewRowNum + " " + row) destinationSheet.getRange(theNewRowNum, 3).setValue("New")//hack &lt;-- this hack was causing issues perhaps? doBook(theNewRowNum, row) } function moveToAlmaJournalRequestsAndEmailUser(obj, templateName, id) { //Lock interface? var destinationSheet = ss.getSheetByName("Alma Journal Requests") var rowNum = getRowNumById(id) moveToSheet(rowNum, toProcessSheet, destinationSheet) destinationSheet.getRange(rowNum, 3).setValue("New")//hack var email = obj['email'] var subject = "Library request update: " + obj["Item Title"] EmailSender(obj, email, subject, templateName) } //////////////////////// END move To Sheet Functions.gs //////////////////////// ///////////////////////// START moveToProcessSheet.gs ///////////////////////// /**
* Move the items to the Alma Borrowing Requests sheet. * This takes each [item] and puts the personal info at the front of it, then appends it to the sheet. * @param {Date} timestamp - When this happens, note not unique as items are duplicated. * @param {string} email - The email of the requester * @param {string} username - e.g tas509 * @param {string} status - Staff|PGR|PGT|UG * @param {string} requestType - "Whole Book or Thesis" | "Book Chapter" | "Journal Article" * @param {Array} items - a list of lists. */
function moveToProcessSheet(timestamp,name, email, username, department, status, requestType,id, items) { for (item of items){ id = Utilities.getUuid();//real - so that each item has ITS OWN id !!!!!!! var row = ['', '', 'New'] //columns A, B, C already filled in...with blanks :-) row = row.concat(item)//Add the data for each item on the end... row = row.concat( [ email, username,department, status, name, requestType, timestamp, id ])//add personal data Logger.log("Adding to Process Sheet: " + row) toProcessSheet.appendRow(row) //// Added colour stuff var lastRow =toProcessSheet.getLastRow() toProcessSheet.getRange("C"+ lastRow).setBackground('lime')//hard-coded... don't slow it down. } //do it in one? } ////////////////////////// END moveToProcessSheet.gs ////////////////////////// /////////////////////////// START moveToAlmaSheet.gs /////////////////////////// /**
* Move the items to the Alma Borrowing Requests sheet. * This takes each [item] and puts the personal info at the front of it, then appends it to the sheet. * @param {Date} timestamp - When this happens, note not unique as items are duplicated. * @param {string} email - The email of the requester * @param {string} username - e.g tas509 * @param {string} status - Staff|PGR|PGT|UG * @param {string} requestType - "Whole Book or Thesis" | "Book Chapter" | "Journal Article" * @param {Array} items - a list of lists. */
function moveToAlmaSheet(timestamp,name, email, username, department, status, requestType,id, items) { Logger.log(`moveToAlmaSheet: ${requestType}`) if (requestType == 'Book Chapter') { var almaSheet = ss.getSheetByName("Alma Chapter Requests") for (item of items) { id = Utilities.getUuid(); //real, so each item has its own id !!!!!!! var row = ['', '', 'New']//columns A, B, C already filled in... row = row.concat(item)//Add the data for each item on the end... row = row.concat([email, username, department, status,name, requestType,timestamp, id])//add personal data Logger.log("Adding to Alma Chapter Sheet: " + row) almaSheet.appendRow(row) var rowNum = almaSheet.getLastRow() doChapter(rowNum, row) } } else if (requestType == 'Journal Article') { var almaSheet = ss.getSheetByName("Alma Journal Requests") for (item of items) { id = Utilities.getUuid();//real !!!!!!! var row = ['', '', 'New']//columns A, B, C already filled in... row = row.concat(item)//Add the data for each item on the end... row = row.concat([ email, username, department, status, name, requestType, timestamp, id])//add personal data Logger.log("Adding to Alma Journal Sheet: " + row) almaSheet.appendRow(row) var rowNum = almaSheet.getLastRow() doJournal(rowNum, row) } } } //////////////////////////// END moveToAlmaSheet.gs //////////////////////////// ////////////////////////// START getEmailQuotaTest.gs ////////////////////////// function getEmailQuota() { var mails = MailApp.getRemainingDailyQuota(); console.log(mails);
} /////////////////////////// END getEmailQuotaTest.gs /////////////////////////// /////////////////////////// START getAPIUserTest.gs /////////////////////////// function findUserTest() { /* retrieve user based on user id */ /*/almaws/v1/users*/ var userid= 'ph847' let baseUrl = "https://api-eu.hosted.exlibrisgroup.com/almaws/v1/users/" + userid + "?user_id_type=all_unique&amp;view=full&amp;expand=none&amp;apikey=l8xxd50b2b9149ad41e8b64e8eadaef2381c"; var headers = { "Content-Type": "application/xml", "Accept": "application/xml", }; var options = { "headers": headers, "method": "GET", "muteHttpExceptions": true, }; //retrieve barcode var response = UrlFetchApp.fetch(baseUrl,options); var status = response.getResponseCode(); Logger.log( "getUser status: " + status); var result = response.getContentText(); if (status == 200){ var responses = XmlService.parse(response); var id_types = new Array; var root = responses.getRootElement(); //check that user's account hasn't expired //staff don't have expiry date var staff_usrs = new Array('35','36','07','20','79'); var usr_type = root.getChild('user_group').getText(); if (staff_usrs.includes(usr_type)){ Logger.log('staff user'); }else{ var exp_date = new Date(root.getChild('expiry_date').getText()); var now = new Date(); if (now &gt;= exp_date){ Logger.log('user has expired'); PropertiesService.getScriptProperties().setProperty('errCode', 'expired'); return('expired'); } } id_types = root.getChild('user_identifiers').getChildren('user_identifier'); //iterate through array of identifiers to find barcode for (var i = 0; i &lt; id_types.length; i++) { var id_typ; id_typ = id_types[i].getChild('id_type').getText(); if (id_typ == ('01')) { var bcode = id_types[i].getChild('value').getText(); return (bcode); } } }else{ var errorResult = XmlService.parse(response); var root = errorResult.getRootElement(); var err= root.getChildren(); var flag = err[1].getChildren(); // &lt;error&gt; var errDets = flag[0].getChildren(); //&lt;error&gt; children errCode = errDets[0].getText(); //errorCode if (errCode == '401861')//user id not found{ MailApp.sendEmail("paul.harding@york.ac.uk", "Tell Us What You Need Error",'Invalid user ID entered on form' ); PropertiesService.getScriptProperties().setProperty('errCode', errCode); }
} //////////////////////////// END getAPIUserTest.gs //////////////////////////// //////////////////////////////// START Test.gs //////////////////////////////// function numOfCells(){ var sheet = ss.getSheetByName("Alma Journal Requests") var lastCol = sheet.getLastColumn() var lastRow = sheet.getLastRow() Logger.log(`${lastRow} ${lastCol}`) Logger.log(lastCol * lastRow)
} function getFilterViews(){ //Started well but nope var requests = [] var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("To Process") var lastRow = sheet.getLastRow()-1 var lastCol = sheet.getLastColumn()-1 var startRowIndex = 0 var startColumnIndex = 0 Logger.log(`${startRowIndex} ${startColumnIndex} ${lastRow} ${lastCol}`) var SpreadsheetID = "1pgmGP-dYDi3nucRoJcbIBwfut79Tjnw07K2rGDFNKGY" var mySheet= Sheets.Spreadsheets.get(SpreadsheetID).sheets[1]; var filteredViews = mySheet.filterViews; //Logger.log(filteredViews) for (filterView of filteredViews){ //optional arguments: startRowIndex, startColumnIndex, endRowIndex, endColumnIndex Logger.log(filterView) //filterSettings.range = {} //var request = {"setBasicFilter": {"filter": filterSettings} } //Sheets.Spreadsheets.batchUpdate({'requests': [request]}, ss.getId());
} /*function setFilter() { var ss = SpreadsheetApp.getActiveSpreadsheet(); var request = { "filterViews": { } }; var filterSettings = {}; // The range of data on which you want to apply the filter. // filterSettings.range = { sheetId: ss.getSheetByName("To Process").getSheetId() // provide your sheetname to which you want to apply filter. }; // Criteria for showing/hiding rows in a filter // https://developers.google.com/sheets/api/reference/rest/v4/FilterCriteria filterSettings.criteria = {}; }*/ ///////////////////////////////// END Test.gs ///////////////////////////////// ]]></description><link>examples/scripting-scripts.html</link><guid isPermaLink="false">Examples/Scripting Scripts.md</guid><pubDate>Tue, 08 Jul 2025 09:41:40 GMT</pubDate></item><item><title><![CDATA[List Drive API by mimetype]]></title><description><![CDATA[Drive API (v2) , iteratively collects files by mimetype ( in this case, PDFs ) in the code.
var ROOT_FOLDER_ID = 'YOUR_ROOT_FOLDER_ID' //&lt;-- CHANGE THIS
function runpngFinder() { // Example for a specific folder: const allpngs = findpngsInFolder(ROOT_FOLDER_ID);// Images folder if (allpngs.length &gt; 0) { Logger.log('Found %s png files.', allpngs.length); // Log results as a JSON string for easy viewing of all data. Logger.log(JSON.stringify(allpngs, null, 2)); // You can also create a spreadsheet with this data: createSpreadsheetWithData(allpngs); } else { Logger.log('No png files were found in the specified folder and its subfolders.'); }
} /** * Finds all png files recursively within a given Google Drive folder using Drive API v2. * * @param {string} folderId The ID of the folder to start searching from. Defaults to the root folder 'root'. * @return {Array&lt;Object&gt;} An array of objects, where each object contains details of a found png. * Returns an empty array if the start folder is not found or is invalid. */
function findpngsInFolder(folderId = 'root') { // This array will hold the final results. const pngsData = []; /** * A recursive helper function to traverse the folder tree. * @param {string} currentFolderId The ID of the folder currently being searched. * @param {string} currentFolderName The name of the folder currently being searched. */ function searchIn(currentFolderId, currentFolderName) { // --- Step 1: Find all png files in the current folder --- try { const fileQuery = `'${currentFolderId}' in parents and mimeType = 'image/png' and trashed = false`; let pageToken; do { const fileList = Drive.Files.list({ q: fileQuery, maxResults: 1000, pageToken: pageToken }); if (fileList.items &amp;&amp; fileList.items.length &gt; 0) { for (const file of fileList.items) { pngsData.push({ fileName: file.title, fileId: file.id, fileUrl: file.alternateLink, parentFolderId: currentFolderId, parentFolderName: currentFolderName }); } } pageToken = fileList.nextPageToken; } while (pageToken); } catch (e) { Logger.log(`Error listing png files in folder "${currentFolderName}" (ID: ${currentFolderId}): ${e.toString()}`); } // --- Step 2: Find all subfolders in the current folder --- try { const folderQuery = `'${currentFolderId}' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false`; let pageToken; do { const folderList = Drive.Files.list({ q: folderQuery, maxResults: 1000, pageToken: pageToken }); // --- Step 3: Recursively call the function for each subfolder --- if (folderList.items &amp;&amp; folderList.items.length &gt; 0) { for (const subfolder of folderList.items) { searchIn(subfolder.id, subfolder.title); } } pageToken = folderList.nextPageToken; } while (pageToken); } catch (e) { Logger.log(`Error listing subfolders in folder "${currentFolderName}" (ID: ${currentFolderId}): ${e.toString()}`); } } // --- Get the name of the starting folder and kick off the search --- let startFolderName; try { if (folderId === 'root') { startFolderName = 'My Drive (Root)'; } else { const folder = Drive.Files.get(folderId); // Ensure it's actually a folder before starting if (folder.mimeType === 'application/vnd.google-apps.folder') { startFolderName = folder.title; } else { Logger.log(`Error: The provided ID "${folderId}" does not belong to a folder.`); return []; // Return empty if ID is not a folder } } // Start the recursive search from the top-level folder searchIn(folderId, startFolderName); } catch (e) { Logger.log(`Error accessing start folder with ID: "${folderId}". Please check if the ID is correct and you have permission. Error: ${e.toString()}`); return []; // Return empty array if start folder is invalid } return pngsData;
} /** * (Optional) Helper function to write the data to a new spreadsheet. * @param {Array&lt;Object&gt;} data The array of png data objects. */
function createSpreadsheetWithData(data) { if (data.length === 0) return; const ss = SpreadsheetApp.create('png Files Report'); const sheet = ss.getActiveSheet(); // Create headers const headers = Object.keys(data[0]); sheet.appendRow(headers); sheet.getRange(1, 1, 1, headers.length).setFontWeight('bold'); // Populate data const rows = data.map(obj =&gt; Object.values(obj)); sheet.getRange(2, 1, rows.length, headers.length).setValues(rows); Logger.log('Report created at: ' + ss.getUrl());
} ]]></description><link>examples/list-drive-api-by-mimetype.html</link><guid isPermaLink="false">Examples/List Drive API by mimetype.md</guid><pubDate>Thu, 03 Jul 2025 18:38:21 GMT</pubDate></item><item><title><![CDATA[Document Comparer]]></title><description><![CDATA[<img alt="Pasted image 20250623114628.png" src="examples/media/pasted-image-20250623114628.png" target="_self">Imagine you have to check or compare a collection of documents, the old one and new version. This is just a handy iframe to help you navigate pairs of documents.This app lets you add a column of URLs to the old documents and a column of new ones.You can then use standard Google comment tools to edit and improve and check the new versions.A wide screen will be useful to use this.Note: The content here is just some random gibberish and Gemini's attempt to turn it into something more meaningful, but equally it could be an updated policy or merely two similar documents.<br>To get your own version of this app, you will need to File &gt; Make a copy of this sheet, and then <a data-tooltip-position="top" aria-label="https://developers.google.com/apps-script/concepts/deployments" rel="noopener nofollow" class="external-link is-unresolved" href="https://developers.google.com/apps-script/concepts/deployments" target="_self">Deploy the web app</a> (to get your own URL)Resources<br>
<a data-tooltip-position="top" aria-label="https://script.google.com/a/macros/york.ac.uk/s/AKfycbxkRnezqP3UO-uHsRAUZJv66sEXMwgoCArRnwB4pyXis_a629UHP1LqA01OEPUo3hLz/exec" rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/a/macros/york.ac.uk/s/AKfycbxkRnezqP3UO-uHsRAUZJv66sEXMwgoCArRnwB4pyXis_a629UHP1LqA01OEPUo3hLz/exec" target="_self">WebApp</a><br>
<a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1mQbJ2rWtfN5q8t114Mb--qBdbLIw3YDbTz1Qyxami0U/edit?gid=470216404#gid=470216404" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1mQbJ2rWtfN5q8t114Mb--qBdbLIw3YDbTz1Qyxami0U/edit?gid=470216404#gid=470216404" target="_self">Sheet</a><br>
<a data-tooltip-position="top" aria-label="https://drive.google.com/drive/folders/1xjOo_Z9Fv6Y2hXs_7dLbpz68yDd-7_yU?ndplr=1" rel="noopener nofollow" class="external-link is-unresolved" href="https://drive.google.com/drive/folders/1xjOo_Z9Fv6Y2hXs_7dLbpz68yDd-7_yU?ndplr=1" target="_self">Folder</a>]]></description><link>examples/document-comparer.html</link><guid isPermaLink="false">Examples/Document Comparer.md</guid><pubDate>Mon, 23 Jun 2025 10:48:21 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Pasted image 20250623114628]]></title><description><![CDATA[<img src="examples/media/pasted-image-20250623114628.png" target="_self">]]></description><link>examples/media/pasted-image-20250623114628.html</link><guid isPermaLink="false">Examples/media/Pasted image 20250623114628.png</guid><pubDate>Mon, 23 Jun 2025 10:46:28 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Insert Names Into Cells...]]></title><description><![CDATA[...as a comma-separated list.<img alt="Insert_Names_Into_Cells_-_Example_-_Google_Sheets.png" src="examples/media/insert_names_into_cells_-_example_-_google_sheets.png" target="_self">A colleague I was teaching AppsScript to, vibe-coded this functionality into their project management sheet. It's a menu to add a list or individual team members (by name) to a cell in a sheet. Genius! Give it a whirl. (You need a Staff list sheet for it to refer to)<br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1NgzOPmAetxQ1PV9kjudHCGrdWhAEAQCgjsVFuU4XQg8/edit?gid=492563862#gid=492563862" target="_self">https://docs.google.com/spreadsheets/d/1NgzOPmAetxQ1PV9kjudHCGrdWhAEAQCgjsVFuU4XQg8/edit?gid=492563862#gid=492563862</a>]]></description><link>examples/insert-names-into-cells....html</link><guid isPermaLink="false">Examples/Insert Names Into Cells....md</guid><pubDate>Fri, 20 Jun 2025 07:23:56 GMT</pubDate><enclosure url="examples/media/insert_names_into_cells_-_example_-_google_sheets.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/insert_names_into_cells_-_example_-_google_sheets.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Insert_Names_Into_Cells_-_Example_-_Google_Sheets]]></title><description><![CDATA[<img src="examples/media/insert_names_into_cells_-_example_-_google_sheets.png" target="_self">]]></description><link>examples/media/insert_names_into_cells_-_example_-_google_sheets.html</link><guid isPermaLink="false">Examples/media/Insert_Names_Into_Cells_-_Example_-_Google_Sheets.png</guid><pubDate>Fri, 20 Jun 2025 07:23:27 GMT</pubDate><enclosure url="examples/media/insert_names_into_cells_-_example_-_google_sheets.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/insert_names_into_cells_-_example_-_google_sheets.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Leading Zero (s) in Sheets]]></title><description><![CDATA[destSheet2.getRange("D" + (rowNum)).setNumberFormat('@STRING@')]]></description><link>examples/leading-zero-(s)-in-sheets.html</link><guid isPermaLink="false">Examples/Leading Zero (s) in Sheets.md</guid><pubDate>Thu, 19 Jun 2025 12:23:23 GMT</pubDate></item><item><title><![CDATA[Dobble Maker]]></title><description><![CDATA[Yes, that's right, a Sheet and AppsScript that makes Slides, that contain the cards to make the card game DOBBLE! You have to print them, and cut them out to play the game.I made a deck that was only Friesian cows.<a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1p23EPX39dw9Yc7-5ZJYrkEdNl8dD5oSvcNw5DGCb5us/edit?gid=0#gid=0" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1p23EPX39dw9Yc7-5ZJYrkEdNl8dD5oSvcNw5DGCb5us/edit?gid=0#gid=0" target="_self">Sheet</a><br><img alt="Pasted image 20250618104403.png" src="examples/media/pasted-image-20250618104403.png" target="_self">]]></description><link>examples/dobble-maker.html</link><guid isPermaLink="false">Examples/Dobble Maker.md</guid><pubDate>Wed, 18 Jun 2025 09:45:30 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Pasted image 20250618104403]]></title><description><![CDATA[<img src="examples/media/pasted-image-20250618104403.png" target="_self">]]></description><link>examples/media/pasted-image-20250618104403.html</link><guid isPermaLink="false">Examples/media/Pasted image 20250618104403.png</guid><pubDate>Wed, 18 Jun 2025 09:44:03 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Org Chart Example]]></title><description><![CDATA[<img alt="Pasted image 20250618103715.png" src="examples/media/pasted-image-20250618103715.png" target="_self"><br><a data-tooltip-position="top" aria-label="https://script.google.com/a/macros/york.ac.uk/s/AKfycbwrMLC9feogyhNDLmQUhQyOT5aulmI-wgmQs8LDWDfDY75luHRaA-QVP--VfGrCQtK-8Q/exec" rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/a/macros/york.ac.uk/s/AKfycbwrMLC9feogyhNDLmQUhQyOT5aulmI-wgmQs8LDWDfDY75luHRaA-QVP--VfGrCQtK-8Q/exec" target="_self">App</a><br><a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1_S8JVwBzcX1x3xwlljqkz0zkHNo-XU4TL4AMAZs137M/edit?usp=sharing" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1_S8JVwBzcX1x3xwlljqkz0zkHNo-XU4TL4AMAZs137M/edit?usp=sharing" target="_self">Sheet</a>]]></description><link>examples/org-chart-example.html</link><guid isPermaLink="false">Examples/Org Chart Example.md</guid><pubDate>Wed, 18 Jun 2025 09:37:25 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Pasted image 20250618103715]]></title><description><![CDATA[<img src="examples/media/pasted-image-20250618103715.png" target="_self">]]></description><link>examples/media/pasted-image-20250618103715.html</link><guid isPermaLink="false">Examples/media/Pasted image 20250618103715.png</guid><pubDate>Wed, 18 Jun 2025 09:37:15 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Redirect Example in AppsScript WebApp]]></title><description><![CDATA[<a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/d/1WO7HmeHmayLexCfBduOsJmJDTqxCIDcRQayCMcQoa7gYrXJ975XAkrwX/edit?usp=sharing" target="_self">https://script.google.com/d/1WO7HmeHmayLexCfBduOsJmJDTqxCIDcRQayCMcQoa7gYrXJ975XAkrwX/edit?usp=sharing</a>]]></description><link>examples/redirect-example-in-appsscript-webapp.html</link><guid isPermaLink="false">Examples/Redirect Example in AppsScript WebApp.md</guid><pubDate>Mon, 16 Jun 2025 09:28:13 GMT</pubDate></item><item><title><![CDATA[Limit Form Character Count]]></title><description><![CDATA[This isn't AppsScript but a way to limit the character count in a Google Form and enters the weird and wonderful world of regular expressions. Now wash your hands.This uses a regex to match a count of any character or fullstop. Try it <a data-tooltip-position="top" aria-label="https://docs.google.com/forms/d/e/1FAIpQLSc09vaox1lCdFFWCs9FTYB8wW0rgpx4gyTkPbBbcl4glxvGsQ/viewform" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/forms/d/e/1FAIpQLSc09vaox1lCdFFWCs9FTYB8wW0rgpx4gyTkPbBbcl4glxvGsQ/viewform" target="_self">here</a><br><img alt="./media/Pasted image 20250515104040.png" src="examples/media/pasted-image-20250515104040.png" target="_self">The regex is this. ^.{0,10}$
]]></description><link>examples/limit-form-character-count.html</link><guid isPermaLink="false">Examples/Limit Form Character Count.md</guid><pubDate>Wed, 04 Jun 2025 14:41:50 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Get Users' Profile URLs]]></title><description><![CDATA[So sometimes you have a sheet of data you want to see who your users are, or use their images by their things. <img alt="./media/ProfilePics.png" src="examples/media/profilepics.png" target="_self">Note: You have to enable the PeopleAPI in the left hand side of the AppsScript Editor. Just add it to your project.<br><img alt="./media/PeopleAPI.png" src="examples/media/peopleapi.png" target="_self">
function test_emailToImage() { Logger.log(getUserPictureUrl("tom.smith@york.ac.uk"))
} function getUserPictureUrl(email) { let defaultPictureUrl = 'https://lh3.googleusercontent.com/a-/AOh14Gj-cdUSUVoEge7rD5a063tQkyTDT3mripEuDZ0v=s100'; try { if (email == '' | email == null) { return defaultPictureUrl } let people = People.People.searchDirectoryPeople({ query: email, readMask: 'photos', sources: 'DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE' }); //Logger.log( people?.people[0]?.photos ) let userPictureUrl = people?.people[0]?.photos[0]?.url; //Logger.log(userPictureUrl) return userPictureUrl ?? defaultPictureUrl; } catch (e) { Logger.log("Error: " + email + " " + e) return defaultPictureUrl } } /* Goes through the entire sheet of data and does every one
Note: in this example, the email is the 3rd column and we're saving the URL and displaying the image into cols D and E. This function will need modifying for your purposes. */
function getAllProfilePictures() { var ss = SpreadsheetApp.getActiveSpreadsheet() var sheet = ss.getSheetByName("Trial Teams")// Change to your sheet name var rows = sheet.getDataRange().getValues() var headers = rows.shift() for (i = 0; i &lt; rows.length; i++) { var row = rows[i] var rowNum = i + 2 var email = row[2].trim() //Zero-based var imageURL = row[3] if (imageURL == "https://lh3.googleusercontent.com/a-/AOh14Gj-cdUSUVoEge7rD5a063tQkyTDT3mripEuDZ0v=s100" | imageURL ==''){ Logger.log(email) var imageURL = getUserPictureUrl(email) sheet.getRange("D" + rowNum).setValue(imageURL) //1-based var formula = `=IMAGE(D${rowNum}, 4, 100, 100)` sheet.getRange("E" + rowNum).setFormula(formula) //1-based Utilities.sleep(3000)// wait for 3 seconds } } Logger.log("Done!") sheet.setRowHeights(2, rows.length, 100 )
} ]]></description><link>examples/get-users'-profile-urls.html</link><guid isPermaLink="false">Examples/Get Users' Profile URLs.md</guid><pubDate>Wed, 04 Jun 2025 14:41:22 GMT</pubDate><enclosure url="examples/media/profilepics.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/profilepics.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[York Username Regex Form Validator]]></title><description><![CDATA[^[a-zA-Z]{2,4}\d{1,4}$
<img alt="./media/Pasted image 20250604153641.png" src="examples/media/pasted-image-20250604153641.png" target="_self"><br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://forms.gle/uNHurwwTiAJZzTSw6" target="_self">https://forms.gle/uNHurwwTiAJZzTSw6</a>]]></description><link>examples/york-username-regex-form-validator.html</link><guid isPermaLink="false">Examples/York Username Regex Form Validator.md</guid><pubDate>Wed, 04 Jun 2025 14:41:13 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Pasted image 20250604153641]]></title><description><![CDATA[<img src="examples/media/pasted-image-20250604153641.png" target="_self">]]></description><link>examples/media/pasted-image-20250604153641.html</link><guid isPermaLink="false">Examples/media/Pasted image 20250604153641.png</guid><pubDate>Wed, 04 Jun 2025 14:36:41 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[PeopleAPI]]></title><description><![CDATA[<img src="examples/media/peopleapi.png" target="_self">]]></description><link>examples/media/peopleapi.html</link><guid isPermaLink="false">Examples/media/PeopleAPI.png</guid><pubDate>Wed, 04 Jun 2025 13:04:11 GMT</pubDate><enclosure url="examples/media/peopleapi.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/peopleapi.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[ProfilePics]]></title><description><![CDATA[<img src="examples/media/profilepics.png" target="_self">]]></description><link>examples/media/profilepics.html</link><guid isPermaLink="false">Examples/media/ProfilePics.png</guid><pubDate>Wed, 04 Jun 2025 13:02:17 GMT</pubDate><enclosure url="examples/media/profilepics.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/profilepics.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Calculate Distance To Campus]]></title><description><![CDATA[This function is designed to be called once, for example in an onFormSubmit(e) function, or when processing rows of data....BUT...This script can be used as a spreadsheet formula like this…=calculateDistance("YO1 4DD", "LS11 0ES")...but you have to uncomment the Utilities.sleep() calls. All this bit of code does is randomize ever so slightly (milliseconds) the calls to the Maps API, so that it doesn't complain that you are hitting the Maps server too simultaneously, and it seems that just randomising when the function is called helps avoid this weird glitch errot.It's normally best to use it in code though, saving the results back into the sheet.
/*
This may error when added to 100s of rows probably, but we might be able to get around that.
*/ function test_distanceFromCampus(){ Logger.log( distanceFromCampus("YO1 7HH") + " miles")// York Minster
} function distanceFromCampus(postCode){//returns miles var [ km,yorkLat,yorkLng, otherLat,otherLng,otherAddress ] = calculateDistance("YO1 4DD", postCode) return km * 0.621371 //this turns it into miles
} function calculateDistance(p1, p2) { var york = Maps.newGeocoder().setRegion('uk').geocode(p1) var yorkLat = york.results[0].geometry.location.lat var yorkLng = york.results[0].geometry.location.lng var otherPlace = Maps.newGeocoder().setRegion('uk').geocode(p2); var otherLat = otherPlace.results[0].geometry.location.lat var otherLng = otherPlace.results[0].geometry.location.lng var otherAddress = otherPlace.results[0].formatted_address var km = distance(yorkLat, yorkLng, otherLat, otherLng) // This mitigates the error sometimes var sleepytime = Math.floor((Math.random()*3000) + 1000); //generate a sleepy time for spreadsheet call locks //Utilities.sleep(sleepytime) return [ km,yorkLat,yorkLng, otherLat,otherLng,otherAddress ]
} function distanceBetweenPostcodes(p1, p2){ var york = Maps.newGeocoder().setRegion('uk').geocode(p1) var yorkLat = york.results[0].geometry.location.lat var yorkLng = york.results[0].geometry.location.lng var otherPlace = Maps.newGeocoder().setRegion('uk').geocode(p2); var otherLat = otherPlace.results[0].geometry.location.lat var otherLng = otherPlace.results[0].geometry.location.lng //var otherAddress = otherPlace.results[0].formatted_address var km = distance(yorkLat, yorkLng, otherLat, otherLng) // This mitigates the error sometimes var sleepytime = Math.floor((Math.random()*3000) + 1000); //generate a sleepy time for spreadsheet call locks //Utilities.sleep(sleepytime) return km } function distance(lat1, lon1, lat2, lon2) { var p = 0.017453292519943295; // Math.PI / 180 var c = Math.cos; var a = 0.5 - c((lat2 - lat1) * p)/2 + c(lat1 * p) * c(lat2 * p) * (1 - c((lon2 - lon1) * p))/2; return 12742 * Math.asin(Math.sqrt(a)); // 2 * R; R = 6371 km
} ]]></description><link>examples/calculate-distance-to-campus.html</link><guid isPermaLink="false">Examples/Calculate Distance To Campus.md</guid><pubDate>Thu, 29 May 2025 08:32:51 GMT</pubDate></item><item><title><![CDATA[Sending PDF Email Attachments]]></title><description><![CDATA[This example project show how you might do that.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://drive.google.com/drive/folders/1pJe6MDtshtbiqPb-yVbMgbEZkMNMPNw-?ndplr=1" target="_self">https://drive.google.com/drive/folders/1pJe6MDtshtbiqPb-yVbMgbEZkMNMPNw-?ndplr=1</a>There are also two commented out examples of how to send HTML , with which you can better format the content of an email. The main code in this project is below./*
Note: This example doesn't send HTML emails, but two examples of different ways
to send HTML emails (that can have nicer formatting) are included.
*/ function onFormSubmit(e) { //Get the timeZone var tmz = Session.getScriptTimeZone() // Connect to the Template Document var templateDocFile = DriveApp.getFileById("1QvmuCEXI7GMFXqWwrgqi5Ji06ejUKtTaXzoiYxLkB4Y") // Connect to "Generated Documents" folder https://drive.google.com/drive/folders/1f4f9AWLjPCR7OCvOSqQeda7Id7ZdimZx?ndplr=1 var folder = DriveApp.getFolderById("1f4f9AWLjPCR7OCvOSqQeda7Id7ZdimZx") // Connect to spreadsheet and get current row var ss = SpreadsheetApp.getActiveSpreadsheet() var sheet = ss.getActiveSheet() var rowNum = e.range.getRow()// the row that is being added var rowData = sheet.getRange(rowNum, 1, 1, sheet.getLastColumn()).getValues()[0] // Get variables from the row (don't use e.namedValues) Logger.log(rowData) Logger.log(rowData[0])//Just checking what the date is var timestamp = new Date(rowData[0]) Logger.log(typeof timestamp) niceTimestamp = Utilities.formatDate(timestamp, tmz, "dd/MM/yyyy HH:mm") var email = rowData[4] var pizza = rowData[1]//2nd column var curry = rowData[2]// 3rd col var mexican = rowData[3] var conspiracy =rowData[5] var why = rowData[6] //Note Logger.log(`${email} : ${pizza}-${curry}-${mexican} ${niceTimestamp}`) //Make a Copy of the Google Template File var documentName = email + ": " + timestamp var copiedFile = templateDocFile.makeCopy() copiedFile.setName(documentName) copiedFile.moveTo(folder) //Now open the copied file with the DocumentApp for editing var copiedDocument = DocumentApp.openById(copiedFile.getId()) var copiedDocumentBody = copiedDocument.getBody() //You can use any chars, I use { } to ensure you aren't replacing the word itself. copiedDocumentBody.replaceText("{niceTimestamp}", niceTimestamp) copiedDocumentBody.replaceText("{Pizza}", pizza) copiedDocumentBody.replaceText("{Curry}", curry) copiedDocumentBody.replaceText("{Mexican}", mexican) copiedDocumentBody.replaceText("{Email}", email) copiedDocumentBody.replaceText("{Which of these conspiracy theories do you think warrants deeper inspection}", conspiracy) copiedDocument.saveAndClose() //////////////////////////// EXAMPLE HTML EMAILS //////////////////////////////// // Examples that show two separate ways of sending prettier HTML emails // var obj = {niceTimestamp:niceTimestamp, pizza:pizza, curry:curry, mexican:mexican, email:email, conspiracy:conspiracy,attachments:[copiedFile.getAs(MimeType.PDF] } // sendHTMLEmailOne("Your chosen dish is..", obj) // sendHTMLEmailTwo("Your chosen dish is..", obj) ///////////////////////// END EXAMPLE HTML EMAILS //////////////////////////////// //Send the email MailApp.sendEmail(email,'Your chosen dish','is attached....',{ noReply: true, name: 'Mexican Curry Pizza selection', attachments: [copiedFile.getAs(MimeType.PDF)], }, ); Logger.log( "Email sent") //Log the fact you have sent an email back into the same row sheet.getRange("H"+rowNum ).setValue("PDF Email sent") copiedFile.setTrashed( true )//delete the Google Document if you like } ]]></description><link>examples/sending-pdf-email-attachments.html</link><guid isPermaLink="false">Examples/Sending PDF Email Attachments.md</guid><pubDate>Thu, 22 May 2025 11:37:46 GMT</pubDate></item><item><title><![CDATA[Pasted image 20250515104040]]></title><description><![CDATA[<img src="examples/media/pasted-image-20250515104040.png" target="_self">]]></description><link>examples/media/pasted-image-20250515104040.html</link><guid isPermaLink="false">Examples/media/Pasted image 20250515104040.png</guid><pubDate>Thu, 15 May 2025 09:40:40 GMT</pubDate><enclosure url="." length="0" type="false"/><content:encoded>&lt;figure&gt;&lt;img src="."&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[HandyLumps - AppsScript Library]]></title><description><![CDATA[Did you know you can create Libraries of code that other AppsScript can use? They are really useful if you want to create some code that when it gets updated, gets updated all over the place.However, I went about it the wrong way. By writing one library, with routines that talked to Google Drive, Gmail, Sheets, Slides, APIs etc... When using adding HandyLumps to your project, you have to give permissions to your script for ALL those services, even if you only use one aspect of the library.So I would recommend not writing your libraries the way I did. Google have also improved the way permissions are handled to be more complicated since I wrote this Library.But, lurking in HandyLumps is a number of useful functions and examples you might want to copy-n-paste, such as:
shareFileWithDriveAPI() which doesn't send a notification email
createEventWithMeetConferencing() to create calendar events with Meets added
renderTemplate(template_id, name, values, folder_id ) useful for making Google Documents from spreadsheet rows.
There's also lots of bits of stuuf like indexNumberToLetter() and columnNumberToLetter(column) and related functions are useful when you're working with AppsScript (zero-based) and Spreadsheets( A1 notation and 1-based )Some of the code is a bit old, but there are a couple of gems lurking in there, go rummage.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/home/projects/1Cha6Ges4fgXn5Edvzl0o6co0bTPPU5RiJReAJaue3QWjNFndKmPcERb3/edit" target="_self">https://script.google.com/home/projects/1Cha6Ges4fgXn5Edvzl0o6co0bTPPU5RiJReAJaue3QWjNFndKmPcERb3/edit</a> ]]></description><link>examples/handylumps-appsscript-library.html</link><guid isPermaLink="false">Examples/HandyLumps - AppsScript Library.md</guid><pubDate>Wed, 14 May 2025 11:43:16 GMT</pubDate></item><item><title><![CDATA[onFormSubmit(e) - My Top Tip]]></title><description><![CDATA[When writing all the code for your onFormSubmit I like to separate the code into a separate function that does something to a row? So instead of looking like this...function onFormSubmit(e){ // Do this code - get values from Form or Row // Do that code - make variables and do the checks // Make a document / folder / whatever // Do the other code - send an Email etc // Save a URL back into the row
}
Instead it would look like this.
function onFormSubmit(e){ var rowNum = e.range.getRow() // the number of the row being added doTheThing(rowNum)
} function doTheThing(rowNum){ var ss = SpreadsheetApp.getActiveSpreadsheet() var sheet = ss.getSheetByName("Form responses 1") var row = sheet.getRange(rowNum,1, 1, sheet.getLastColumn())[0] // Do all the code you woulda done }
Well, firstly, whatever you are doing with the data coming in, there are many times when you might want to have that functionality used in a Spreadsheet Menu... and so you can then re-use the code from your onFormSubmit without copy-n-pasting it. Marvellous! And less code to maintain.If your Google Form is big or even just biggish, or has required items, most times your code doesn't work first time (or is this just me?). You often want to resubmit your Form, to test your code, but you don't want to resubmit your code. If I have structured my project like the above, I can now easily create a test function like this.
function test_onFormSubmit(){ var rowNum = 11 // a test row I added earlier doTheThing(rowNum) }
Now you can re process the data in that row without having to re submit the form.Lastly, you can now use the debugger to test your code, and see what all the values in your variables as you step through your code line by line.AND, if things ever go wrong with your AppsScript you have a handy dandy way of checking things over to work out what's going wrong.]]></description><link>examples/onformsubmit(e)-my-top-tip.html</link><guid isPermaLink="false">Examples/onFormSubmit(e) - My Top Tip.md</guid><pubDate>Wed, 14 May 2025 10:19:09 GMT</pubDate></item><item><title><![CDATA[Document Creator]]></title><description><![CDATA[This Sheet and Apps Script creates a menu so that you can turn spreadsheet data rows into individual documents.<img alt="74bfde0bf6a3e22b6c45431c720ea465.png" src="examples/media/74bfde0bf6a3e22b6c45431c720ea465.png" target="_self"><br><a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1ruHSSalKkLYdU1hf_9OCEx5cUp5ynGvrb2_wp0u05hU/copy" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1ruHSSalKkLYdU1hf_9OCEx5cUp5ynGvrb2_wp0u05hU/copy" target="_self">Example Document Creator Sheet</a>]]></description><link>examples/document-creator.html</link><guid isPermaLink="false">Examples/Document Creator.md</guid><pubDate>Tue, 15 Apr 2025 15:23:18 GMT</pubDate><enclosure url="examples/media/74bfde0bf6a3e22b6c45431c720ea465.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/74bfde0bf6a3e22b6c45431c720ea465.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Example APIs]]></title><description><![CDATA[This sheet is just a collection of examples that use various free APIs, such as:
Moonphase
Metropolitan Museum API
Cat As A Service
Dad Jokes
etc etc
<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1RZ6l7n4RLwl9kV8FGQfHLoblUPRpFfjmxQXvuMlBNdA/edit#gid=861110954" target="_self">https://docs.google.com/spreadsheets/d/1RZ6l7n4RLwl9kV8FGQfHLoblUPRpFfjmxQXvuMlBNdA/edit#gid=861110954</a>]]></description><link>examples/example-apis.html</link><guid isPermaLink="false">Examples/Example APIs.md</guid><pubDate>Tue, 15 Apr 2025 15:11:25 GMT</pubDate></item><item><title><![CDATA[YouTube API]]></title><description><![CDATA[This simple script uses the YouTube API and returns a list of videos for search terms.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/126FWlOh8o7OHuCxmrwNAjgBZCAUxdE1Ka_kVD4qJN3M/copy" target="_self">https://docs.google.com/spreadsheets/d/126FWlOh8o7OHuCxmrwNAjgBZCAUxdE1Ka_kVD4qJN3M/copy</a>]]></description><link>examples/youtube-api.html</link><guid isPermaLink="false">Examples/YouTube API.md</guid><pubDate>Tue, 15 Apr 2025 15:10:21 GMT</pubDate></item><item><title><![CDATA[URL Shortening]]></title><description><![CDATA[TinyURL are interesting because all you need to do to use their services is to request a url with your url in it. This means one can use UrlFetchApp to do this and save the result.function tinyURL(url) { try{ var full_url = 'http://tinyurl.com/api-create.php?url=' + url var resultText = UrlFetchApp.fetch(full_url).getContentText() return resultText } catch(e){ Logger.log("TinyURL: " + e) }
} ]]></description><link>examples/url-shortening.html</link><guid isPermaLink="false">Examples/URL Shortening.md</guid><pubDate>Tue, 15 Apr 2025 15:10:12 GMT</pubDate></item><item><title><![CDATA[Using TextFinder to Lookup rows]]></title><description><![CDATA[Using a <a data-tooltip-position="top" aria-label="https://developers.google.com/apps-script/reference/spreadsheet/text-finder" rel="noopener nofollow" class="external-link is-unresolved" href="https://developers.google.com/apps-script/reference/spreadsheet/text-finder" target="_self">TextFinder</a> rather than the more normal approach "a. get all the data and b. loop through the data until you find what you want' to look up a row containing some certain data you are looking for” can be significantly sped up.
function test_searchString(){ var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Events") var search = "Freya Sierhuis" Logger.log( searchString(sheet, search))
}
function searchString(sheet, search_string){ // See: https://stackoverflow.com/questions/18482143/search-spreadsheet-by-column-return-rows var textFinder = sheet.createTextFinder(search_string) var search_row = textFinder.findNext().getRow() return search_row
} /*
I ran doLookups() in a sheet with 4,000 rows, where column 2 is unique, and got this:
3:08:01 PM Info lookup3 took 0.672 seconds
3:08:01 PM Info 4263.0
3:08:02 PM Info lookup1 took 0.252 seconds
3:08:02 PM Info 4263.0
3:08:02 PM Info lookup2 took 0.671 seconds
3:08:02 PM Info 4263.0
3:08:03 PM Info lookup2 took 0.681 seconds
3:08:03 PM Info 4263.0
3:08:04 PM Info lookup3 took 0.652 seconds
3:08:04 PM Info 4263.0
3:08:04 PM Info lookup1 took 0.356 seconds
3:08:04 PM Info 4263.0
*/
function doLookups(){ //Run this one let text = "wrangwise" Logger.log(lookup3(text)) Logger.log(lookup1(text)) Logger.log(lookup2(text)) // Do 'em again in a different order to see if they're cached Logger.log(lookup2(text)) Logger.log(lookup3(text)) Logger.log(lookup1(text))
}
function lookup1(text){ //Use a TextFinder: https://developers.google.com/apps-script/reference/spreadsheet/text-finder#findNext() let ss = SpreadsheetApp.openById("1v3umP3ZRPcGRolpJ8rarxihzqO9qKzTnE8udqQdWuco") let sheet = ss.getSheetByName("Words") let startDate = new Date() //Search the first column var found = sheet.getRange(1,2,sheet.getLastRow() ).createTextFinder(text).matchCase(false).findNext(); let endDate = new Date() var seconds = (endDate.getTime() - startDate.getTime() ) / 1000; Logger.log( `lookup1 took ${seconds} seconds`) return found.getRowIndex()
}
function lookup2(text){ //Classic oldskool let ss = SpreadsheetApp.openById("1v3umP3ZRPcGRolpJ8rarxihzqO9qKzTnE8udqQdWuco") let sheet = ss.getSheetByName("Words") let startDate = new Date() var values = sheet.getRange(1,2,sheet.getLastRow() ).getValues(); var found; for (var i = 0; i &lt; values.length; i++){ if (values[i][0] === text) { found = i; break; } } let endDate = new Date() var seconds = (endDate.getTime() - startDate.getTime() ) / 1000; Logger.log( `lookup2 took ${seconds} seconds`) return found+1
}
function lookup3(text){ //Uses ES8's [Array].findIndex() There may be a better way (map?, filter?) let ss = SpreadsheetApp.openById("1v3umP3ZRPcGRolpJ8rarxihzqO9qKzTnE8udqQdWuco") let sheet = ss.getSheetByName("Words") let startDate = new Date() var values = sheet.getRange(1,2,sheet.getLastRow() ).getValues(); var found = values.findIndex(function(value){ return value == text }) let endDate = new Date() var seconds = (endDate.getTime() - startDate.getTime() ) / 1000; Logger.log( `lookup3 took ${seconds} seconds`) return found+1
} ]]></description><link>examples/using-textfinder-to-lookup-rows.html</link><guid isPermaLink="false">Examples/Using TextFinder to Lookup rows.md</guid><pubDate>Tue, 15 Apr 2025 15:09:45 GMT</pubDate></item><item><title><![CDATA[Student Folder Maker]]></title><description><![CDATA[<img alt="9f734ac23d950c8a43b4a22b86b35e84.png" src="examples/media/9f734ac23d950c8a43b4a22b86b35e84.png" target="_self">Enter a list of students, a template folder's id, and then generate folders for students.<br>
<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1XqaxlVKVYTIWybILa4ZHK4Qn0aDQaMpPI4GQq2w4YMU/edit#gid=2065367635" target="_self">https://docs.google.com/spreadsheets/d/1XqaxlVKVYTIWybILa4ZHK4Qn0aDQaMpPI4GQq2w4YMU/edit#gid=2065367635</a>]]></description><link>examples/student-folder-maker.html</link><guid isPermaLink="false">Examples/Student Folder Maker.md</guid><pubDate>Tue, 15 Apr 2025 15:09:35 GMT</pubDate><enclosure url="examples/media/9f734ac23d950c8a43b4a22b86b35e84.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/9f734ac23d950c8a43b4a22b86b35e84.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Slides ALT Maker]]></title><description><![CDATA[<img alt="f5c22d0cee1bf74a25fe1e4a5c0b233e.png" src="examples/media/f5c22d0cee1bf74a25fe1e4a5c0b233e.png" target="_self">This tool reads in all of a Slides deck's images and each image's ALT text. You can then edit and create/update the ALT texts and send them back to your slide deck<br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1e3l0tq9HIAxo3tFOCGNZ1BBt5IZl2acnarXtLm_07u4/edit?gid=0#gid=0" target="_self">https://docs.google.com/spreadsheets/d/1e3l0tq9HIAxo3tFOCGNZ1BBt5IZl2acnarXtLm_07u4/edit?gid=0#gid=0</a>]]></description><link>examples/slides-alt-maker.html</link><guid isPermaLink="false">Examples/Slides ALT Maker.md</guid><pubDate>Tue, 15 Apr 2025 15:09:21 GMT</pubDate><enclosure url="examples/media/f5c22d0cee1bf74a25fe1e4a5c0b233e.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/f5c22d0cee1bf74a25fe1e4a5c0b233e.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Sending HTML Emails]]></title><description><![CDATA[This example shows lots of the different ways you might want to create a HTML email. These include Using string variables and + them together
Using Javascript literal strings
Using AppScript's HTMLService Templates
<a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/home/projects/1ftSXINZcB7QYqRKhRb4axmYMBYTufokf9_CjL854YkgNUKZA2GPcJCna/edit" target="_self">https://script.google.com/home/projects/1ftSXINZcB7QYqRKhRb4axmYMBYTufokf9_CjL854YkgNUKZA2GPcJCna/edit</a>]]></description><link>examples/sending-html-emails.html</link><guid isPermaLink="false">Examples/Sending HTML Emails.md</guid><pubDate>Tue, 15 Apr 2025 15:09:04 GMT</pubDate></item><item><title><![CDATA[Self Reducing Dropdown]]></title><description><![CDATA[<a href=".?query=tag:onEdit" class="tag is-unresolved" target="_self" rel="noopener nofollow" data-href="#onEdit">#onEdit</a> <a href=".?query=tag:sheets" class="tag is-unresolved" target="_self" rel="noopener nofollow" data-href="#sheets">#sheets</a><br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1Stp-0PNjZnm3V0j74PRIaT8UjVe62xGwkTL9ToQdqqs/edit#gid=1669632667" target="_self">https://docs.google.com/spreadsheets/d/1Stp-0PNjZnm3V0j74PRIaT8UjVe62xGwkTL9ToQdqqs/edit#gid=1669632667</a>As you select an item from a list, it is removed from the options available.Useful perhaps is you want to assign certain options (in this case, schools) to certain rows, but only once.Note: This sheet uses two columns and a =QUERY(Example!schools, "select A, B where B = 0", false)
spreadsheet formula]]></description><link>examples/self-reducing-dropdown.html</link><guid isPermaLink="false">Examples/Self Reducing Dropdown.md</guid><pubDate>Tue, 15 Apr 2025 15:08:52 GMT</pubDate></item><item><title><![CDATA[Scheduled Gmail Send]]></title><description><![CDATA[Note: This isn't really needed now that <a data-tooltip-position="top" aria-label="https://support.google.com/mail/answer/9214606?hl=en-GB&amp;co=GENIE.Platform%3DDesktop" rel="noopener nofollow" class="external-link is-unresolved" href="https://support.google.com/mail/answer/9214606?hl=en-GB&amp;co=GENIE.Platform%3DDesktop" target="_self">Gmail has Scheduled Send feature</a>.<br>This <a data-tooltip-position="top" aria-label="https://script.google.com/d/1KHyzwFnvqEWVvN3X5KdRyYJ6PimTSK5q6rvlpea2tvVLQdtPgTbedLPM/edit?usp=sharing" rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/d/1KHyzwFnvqEWVvN3X5KdRyYJ6PimTSK5q6rvlpea2tvVLQdtPgTbedLPM/edit?usp=sharing" target="_self">script</a> shows how you can use your Gmail Drafts folder to send out emails at a specified time. If you give your drafts a subject like this ...	[23/07/2014 07:30] Wakey wakey! ...then this script will check your Drafts folder in Gmail and send. The draft will be moved to bin, but labelled as "Scheduled Gmail Sent". Note: This script also contains an example of how to use regular expressions.var regex = /^\\\[(\[0-9]\[0-9]\\/\[0-9]\[0-9]\\/\[0-9]\[0-9]\[0-9]\[0-9] \*\[0-9]?\[0-9]:\[0-9]\[0-9])\\] (.\*$)/gm var result = regex.exec( subject )
`]]></description><link>examples/scheduled-gmail-send.html</link><guid isPermaLink="false">Examples/Scheduled Gmail Send.md</guid><pubDate>Tue, 15 Apr 2025 15:08:44 GMT</pubDate></item><item><title><![CDATA[QR Code Maker]]></title><description><![CDATA[<img alt="8cfe952a80944acaf7871e128e9eefb8.png" src="examples/media/8cfe952a80944acaf7871e128e9eefb8.png" target="_self">This uses an external Javascript file to generate an SVG barcode of a Sheet of URLs, and saves the QRcode image file both as SVG, and a PNG.<br>
<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1kdFw5RLhW18VwzZGzMU6upoVMbMN7Ikmmdgclbgq3cA/edit?gid=618697165#gid=618697165" target="_self">https://docs.google.com/spreadsheets/d/1kdFw5RLhW18VwzZGzMU6upoVMbMN7Ikmmdgclbgq3cA/edit?gid=618697165#gid=618697165</a>]]></description><link>examples/qr-code-maker.html</link><guid isPermaLink="false">Examples/QR Code Maker.md</guid><pubDate>Tue, 15 Apr 2025 15:08:39 GMT</pubDate><enclosure url="examples/media/8cfe952a80944acaf7871e128e9eefb8.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/8cfe952a80944acaf7871e128e9eefb8.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[p5js and ml5 in Sheets]]></title><description><![CDATA[<img alt="612d47db27a26c39b15ad95d14eb1a8e.png" src="examples/media/612d47db27a26c39b15ad95d14eb1a8e.png" target="_self"><br><a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1n_kP0LAv53edrmGH67tDMJqebexTb0NYAkI1JicP0zg/edit?gid=0#gid=0" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1n_kP0LAv53edrmGH67tDMJqebexTb0NYAkI1JicP0zg/edit?gid=0#gid=0" target="_self">This sheet</a> shows a P5js canvas in a Spreadsheet Dialog window.It also shows how to pass an image in Google Drive into a sketch (and use ml5 to categorise the image). I don't think ml5's categorisation is any good, but it shows that it can be done and how to do it.]]></description><link>examples/p5js-and-ml5-in-sheets.html</link><guid isPermaLink="false">Examples/p5js and ml5 in Sheets.md</guid><pubDate>Tue, 15 Apr 2025 15:08:23 GMT</pubDate><enclosure url="examples/media/612d47db27a26c39b15ad95d14eb1a8e.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/612d47db27a26c39b15ad95d14eb1a8e.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[One To Many Example]]></title><description><![CDATA[Google Sheets at times can be a bit dumb. Sometimes you need a column's cells to contain multiple comma-separated items. Which makes it really easy to add one-to-many (in database parlance) columns to your sheets.A good example might be a column called "categories" where you might want to add categories such as "coding, graphics, javascript, visualisation" or a column called ingredients, and you want to add "egg, bacon, sausage, beans, mushrooms, toast" to a row about a Full English Breakfast.<a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1TV_Bkb9Zv-2h75PGrN_wZtARdkvCpORvY4RyBHBDCVU/edit#gid=0" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1TV_Bkb9Zv-2h75PGrN_wZtARdkvCpORvY4RyBHBDCVU/edit#gid=0" target="_self">This sheet</a> has some script that :a. Looks at the validation you have on a particular cell
b. Shows a dialog in the Admin menu that allows you to select items.<br>This <a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1mlBP_ctbaOqRbvz1VaOZ2F9R5FZvtUZfP82BBMbKHt4/edit#gid=947625069" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1mlBP_ctbaOqRbvz1VaOZ2F9R5FZvtUZfP82BBMbKHt4/edit#gid=947625069" target="_self">sheet</a> looks at combining this way of working with Smart Chips too.]]></description><link>examples/one-to-many-example.html</link><guid isPermaLink="false">Examples/One To Many Example.md</guid><pubDate>Tue, 15 Apr 2025 15:07:55 GMT</pubDate></item><item><title><![CDATA[Objectify]]></title><description><![CDATA[This Objectify code turns arrays of arrays into arrays of javascript objects. I generally use it, especially if there are lots of columns, and the Google Form might change, because fixing up names is easier than fixing up indexes of columns (in your code.)It can do all your data, one row of data, or get some data but instead of creating an array of objects, it creates an object of objects? This means you can lookup the one object you want by a key rather than iterating through a list and checking them (this function is objectifyWithKey).
/*
I often don't like working with lists of data. Sometimes I want to work with Javascript objects.
It means people can, in theory add or rename columns and you can at least recover. */ /*
objectify(Array headers, ArrayList rows): Used to turn rows of data into a list of named objects, like this: [{hair=None, name=Bert, age=81.0}, {hair=Blond, name=Sally, age=34.0}, {hair=Black, name=Alf , age=68.0}, {hair=None, name=Stan, age=69.0}, {hair=Grey, name=Hilda, age=66.0}, {hair=Ginger, name=Fred, age=44.0}] so you can: var objectList = objectify(headers, rows) Logger.log( objectList[1].hair) //get 2nd row's hair * @param &lt;Array&gt; column headers as list
* @param &lt;Array&gt; rows as list
* @return [{}, {}...] a list of objects
*/ function objectify(headers, rows){ var newarray=[] var obj for(var y = 0; y &lt; rows.length; y++){ obj = {}; for(var i = 0; i &lt; headers.length; i++){ obj[headers[i]] = rows[y][i]; } newarray.push(obj) } return newarray }
/*
* @param &lt;Sheet&gt; the sheet you want to use
* @param &lt;Integer&gt; row number
* @param &lt;Integer&gt; header row number, usually 1 * @return {} a single object/row
*/ function objectifyOne( sheet, rowNum, headerRowNum){ var hNum = headerRowNum | 1 //Uses 1 if none passed in. var headers = sheet.getRange( hNum, 1, 1, sheet.getLastColumn()).getValues()[0] var data = sheet.getRange( rowNum,1, 1, sheet.getLastColumn()).getValues() var object = objectify(headers, data)[0] return object }
function test_objectifyOne(){ logSheet.getRange("A4").setValue( objectifyOne( sheet, 52))
} /*////////////// objectifyWithKey(Array headers, ArrayList rows, String key): returns: {Sally={hair=Blond, name=Sally, age=34.0}, Stan={hair=None, name=Stan, age=69.0}, Bert={hair=None, name=Bert, age=81.0}, Alf ={hair=Black, name=Alf , age=68.0}, Fred={hair=Ginger, name=Fred, age=44.0}, Hilda={hair=Grey, name=Hilda, age=66.0}} so you can: var objectDict = objectifyWithKey(headers, rows, "name") //key has to be unique, otherwise will overwrite Logger.log(objectDict['Fred'].hair) //Note I can get a row by its keyed item. &gt;&gt; "Ginger"
} *
* @param &lt;Array&gt; headers
* @param &lt;Array&gt; rows
* @param &lt;String&gt; key name * @return [{},{}] A list of objects.
*/ function objectifyWithKey(headers, rows, key){ var newarray=[] var keyedObj = {} for(var y = 0; y &lt; rows.length; y++){ obj = {rowNum: y+2}; for(var i = 0; i &lt; headers.length; i++){ obj[headers[i]] = rows[y][i]; } keyedObj[obj[key]] = obj //leave the key in } return keyedObj } ]]></description><link>examples/objectify.html</link><guid isPermaLink="false">Examples/Objectify.md</guid><pubDate>Tue, 15 Apr 2025 15:07:52 GMT</pubDate></item><item><title><![CDATA[numberToEnglishWords]]></title><description><![CDATA[This code can be useful for UX things. It turns a number like this, ‘_13523 is thirteen thousand five hundred and twenty three_’
This fun code sample contains a test function you can run to see how it works. It also shows how I (for most functions) choose to create a test_function() so that I can run the code standalone, or use it in other contexts.The code is <a data-tooltip-position="top" aria-label="https://script.google.com/u/0/home/projects/1dNChFSj_MzOvT1H8SDRaOj3NcQZKffTI3WRByCz2WqqzkFYjdn7jdzNG/edit" rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/u/0/home/projects/1dNChFSj_MzOvT1H8SDRaOj3NcQZKffTI3WRByCz2WqqzkFYjdn7jdzNG/edit" target="_self">here</a>.
function test_numberToEnglish(){ var num = 123 //1,234,567 Logger.log( num + " is " + numberToEnglish(num) ) } function numberToEnglish( n ) { var string = n.toString(), units, tens, scales, start, end, chunks, chunksLen, chunk, ints, i, word, words, and = 'and'; /* Remove spaces and commas */ string = string.replace(/[, ]/g,""); /* Is number zero? */ if( parseInt( string ) === 0 ) { return 'zero'; } /* Array of units as words */ units = [ '', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen' ]; /* Array of tens as words */ tens = [ '', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety' ]; /* Array of scales as words */ scales = [ '', 'thousand', 'million', 'billion', 'trillion', 'quadrillion', 'quintillion', 'sextillion', 'septillion', 'octillion', 'nonillion', 'decillion', 'undecillion', 'duodecillion', 'tredecillion', 'quatttuor-decillion', 'quindecillion', 'sexdecillion', 'septen-decillion', 'octodecillion', 'novemdecillion', 'vigintillion', 'centillion' ]; /* Split user argument into 3 digit chunks from right to left */ start = string.length; chunks = []; while( start &gt; 0 ) { end = start; chunks.push( string.slice( ( start = Math.max( 0, start - 3 ) ), end ) ); } /* Check if function has enough scale words to be able to stringify the user argument */ chunksLen = chunks.length; if( chunksLen &gt; scales.length ) { return ''; } /* Stringify each integer in each chunk */ words = []; for( i = 0; i &lt; chunksLen; i++ ) { chunk = parseInt( chunks[i] ); if( chunk ) { /* Split chunk into array of individual integers */ ints = chunks[i].split( '' ).reverse().map( parseFloat ); /* If tens integer is 1, i.e. 10, then add 10 to units integer */ if( ints[1] === 1 ) { ints[0] += 10; } /* Add scale word if chunk is not zero and array item exists */ if( ( word = scales[i] ) ) { words.push( word ); } /* Add unit word if array item exists */ if( ( word = units[ ints[0] ] ) ) { words.push( word ); } /* Add tens word if array item exists */ if( ( word = tens[ ints[1] ] ) ) { words.push( word ); } /* Add 'and' string after units or tens integer if: */ if( ints[0] || ints[1] ) { /* Chunk has a hundreds integer or chunk is the first of multiple chunks */ if( ints[2] || ! i &amp;&amp; chunksLen ) { words.push( and ); } } /* Add hundreds word if array item exists */ if( ( word = units[ ints[2] ] ) ) { words.push( word + ' hundred' ); } } } return words.reverse().join( ' ' ); } ]]></description><link>examples/numbertoenglishwords.html</link><guid isPermaLink="false">Examples/numberToEnglishWords.md</guid><pubDate>Tue, 15 Apr 2025 15:07:43 GMT</pubDate></item><item><title><![CDATA[Newsletter Submissions]]></title><description><![CDATA[<a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/u/0/home/projects/1lMSQDtFiuy8CE1tJ_8htt_JzamfalXbwTZhMZg5eLMLcLiGve-a_b-R3/edit" target="_self">https://script.google.com/u/0/home/projects/1lMSQDtFiuy8CE1tJ_8htt_JzamfalXbwTZhMZg5eLMLcLiGve-a_b-R3/edit</a>This example shows how to use a Google Form to gather submissions to a newsletter (a Google Document). Usefully, it creates a separate file for each "month" and then appends the submission to the correct respective files - creating a really useful archive of newsletters that users can search or catch up with.]]></description><link>examples/newsletter-submissions.html</link><guid isPermaLink="false">Examples/Newsletter Submissions.md</guid><pubDate>Tue, 15 Apr 2025 15:07:31 GMT</pubDate></item><item><title><![CDATA[Moment.js and Dates]]></title><description><![CDATA[Dates are difficult. Really difficult. This example shows how to load and use a Javascript library that helps you do things like add days to a date, or turn US dates into UK dates easily. <a data-tooltip-position="top" aria-label="https://script.google.com/home/projects/1ba-hR3sPNt7ikBn7CYTR6sZZ7T9IDfwt4S0JkqBEsrfjsJbZXYmA91Qj/edit" rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/home/projects/1ba-hR3sPNt7ikBn7CYTR6sZZ7T9IDfwt4S0JkqBEsrfjsJbZXYmA91Qj/edit" target="_self">Script here</a>.]]></description><link>examples/moment.js-and-dates.html</link><guid isPermaLink="false">Examples/Moment.js and Dates.md</guid><pubDate>Tue, 15 Apr 2025 15:07:23 GMT</pubDate></item><item><title><![CDATA[MailApp Options Example]]></title><description><![CDATA[Sometimes you want to send an email with AppsScript setting the name, or replyTo address, or using a noReply email...
Make a copy of <a data-tooltip-position="top" aria-label="https://script.google.com/home/projects/1xtpEnwe-JD3htKJ4MOEEATNxAgXAqZbgHIoWgGGy0jiM9GQV4lAG5g7o/edit" rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/home/projects/1xtpEnwe-JD3htKJ4MOEEATNxAgXAqZbgHIoWgGGy0jiM9GQV4lAG5g7o/edit" target="_self">this code</a>
Edit with your details etc
Run to receive two example emails
This is just useful to actually send emails, and then see how they present in your Gmail, so you can try out the different varieties of options and see them ‘in real life’ in your email.<br>This demo also shows how to create a HTML email using <a data-tooltip-position="top" aria-label="https://www.w3schools.com/js/tryit.asp?filename=tryjs_templates_variables" rel="noopener nofollow" class="external-link is-unresolved" href="https://www.w3schools.com/js/tryit.asp?filename=tryjs_templates_variables" target="_self">template literal strings</a>, a REALLY useful feature of Javascript/AppsScript.]]></description><link>examples/mailapp-options-example.html</link><guid isPermaLink="false">Examples/MailApp Options Example.md</guid><pubDate>Tue, 15 Apr 2025 15:07:12 GMT</pubDate></item><item><title><![CDATA[List of MPs From Wikipedia]]></title><description><![CDATA[Example of using **=IMPORTHTML()** spreadsheet formula to bring in a list or table from Wikipedia.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/186jX3Q6kZFfu6OHSZ2nxMrCf8d-keOXRg1cpPI7g6Ko/edit#gid=0" target="_self">https://docs.google.com/spreadsheets/d/186jX3Q6kZFfu6OHSZ2nxMrCf8d-keOXRg1cpPI7g6Ko/edit#gid=0</a>]]></description><link>examples/list-of-mps-from-wikipedia.html</link><guid isPermaLink="false">Examples/List of MPs From Wikipedia.md</guid><pubDate>Tue, 15 Apr 2025 15:07:09 GMT</pubDate></item><item><title><![CDATA[List Files With Permissions]]></title><description><![CDATA[Use Drive APIExamples of using the Drive API to get information about files.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/12n2kW_zQpD7B2Un0vbovHILFBG_fos3W9fI3mE1kI-A/edit?gid=1138046651#gid=1138046651" target="_self">https://docs.google.com/spreadsheets/d/12n2kW_zQpD7B2Un0vbovHILFBG_fos3W9fI3mE1kI-A/edit?gid=1138046651#gid=1138046651</a><br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/15tfNUtrC8dPweBU6726rMNQRfnt41vQv1jXciFn7dU0/edit?gid=1138046651#gid=1138046651" target="_self">https://docs.google.com/spreadsheets/d/15tfNUtrC8dPweBU6726rMNQRfnt41vQv1jXciFn7dU0/edit?gid=1138046651#gid=1138046651</a>]]></description><link>examples/list-files-with-permissions.html</link><guid isPermaLink="false">Examples/List Files With Permissions.md</guid><pubDate>Tue, 15 Apr 2025 15:07:01 GMT</pubDate></item><item><title><![CDATA[List File Sizes Using DriveApp]]></title><description><![CDATA[<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1rEZz1l81Up_xWe8ebmkRx2ObMu5AlAo6DubYXCKc-sg/edit?gid=0#gid=0" target="_self">https://docs.google.com/spreadsheets/d/1rEZz1l81Up_xWe8ebmkRx2ObMu5AlAo6DubYXCKc-sg/edit?gid=0#gid=0</a>]]></description><link>examples/list-file-sizes-using-driveapp.html</link><guid isPermaLink="false">Examples/List File Sizes Using DriveApp.md</guid><pubDate>Tue, 15 Apr 2025 15:06:57 GMT</pubDate></item><item><title><![CDATA[Incrementing Number]]></title><description><![CDATA[Example of how to give people an incrementing number.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1AR38Du_cafY18yE3ZV106iZhuzk2GzEfUuf6aMP_WPE/edit#gid=2129924018" target="_self">https://docs.google.com/spreadsheets/d/1AR38Du_cafY18yE3ZV106iZhuzk2GzEfUuf6aMP_WPE/edit#gid=2129924018</a>]]></description><link>examples/incrementing-number.html</link><guid isPermaLink="false">Examples/Incrementing Number.md</guid><pubDate>Tue, 15 Apr 2025 15:06:40 GMT</pubDate></item><item><title><![CDATA[HTML Editor in Sheets]]></title><description><![CDATA[If your sheet contains HTML text, or HTML fragments. - make the HTML easier to edit with a WYSIWYG editor called Quill.<img alt="96ea0e1db255b3020637bd643b3107cd.png" src="examples/media/96ea0e1db255b3020637bd643b3107cd.png" target="_self"><br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/17oty3sV3b7ATZXieu3T9qWHvpvr8MvFfcOdfb7upLpk/edit#gid=0" target="_self">https://docs.google.com/spreadsheets/d/17oty3sV3b7ATZXieu3T9qWHvpvr8MvFfcOdfb7upLpk/edit#gid=0</a>]]></description><link>examples/html-editor-in-sheets.html</link><guid isPermaLink="false">Examples/HTML Editor in Sheets.md</guid><pubDate>Tue, 15 Apr 2025 15:06:35 GMT</pubDate><enclosure url="examples/media/96ea0e1db255b3020637bd643b3107cd.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/96ea0e1db255b3020637bd643b3107cd.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[How To Use Drive Images in LookerStudio]]></title><description><![CDATA[How to make your Looker Studio reports use images hosted in Google Drive.<img alt="1137f602f33424c96992c8354b81241a.png" src="examples/media/1137f602f33424c96992c8354b81241a.png" target="_self"><br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1-zEiOJlxhNH2f3CCiMavR-v_IPVCx0caHiEJZcoj3yI/edit?gid=0#gid=0" target="_self">https://docs.google.com/spreadsheets/d/1-zEiOJlxhNH2f3CCiMavR-v_IPVCx0caHiEJZcoj3yI/edit?gid=0#gid=0</a>]]></description><link>examples/how-to-use-drive-images-in-lookerstudio.html</link><guid isPermaLink="false">Examples/How To Use Drive Images in LookerStudio.md</guid><pubDate>Tue, 15 Apr 2025 15:06:14 GMT</pubDate><enclosure url="examples/media/1137f602f33424c96992c8354b81241a.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/1137f602f33424c96992c8354b81241a.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Google FilePickers With Folder and Sheet Creation]]></title><description><![CDATA[This file shows how to create files, folders, sheets and spreadsheets in a large variety of ways.You can make a copy of this sheet and use it for your learning /hacking purposes.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1n0Lg3VVqbHrlbCgkKo_8tpLGUeKlxJheluzAQEkk78I/edit#gid=1908753730" target="_self">https://docs.google.com/spreadsheets/d/1n0Lg3VVqbHrlbCgkKo_8tpLGUeKlxJheluzAQEkk78I/edit#gid=1908753730</a>]]></description><link>examples/google-filepickers-with-folder-and-sheet-creation.html</link><guid isPermaLink="false">Examples/Google FilePickers With Folder and Sheet Creation.md</guid><pubDate>Tue, 15 Apr 2025 15:06:01 GMT</pubDate></item><item><title><![CDATA[Google File Pickers Example]]></title><description><![CDATA[See the Admin menu in this sheet for how to use Google File Pickers.<img alt="30c37c206d2475b52dc62df4af65a5cb.png" src="examples/media/30c37c206d2475b52dc62df4af65a5cb.png" target="_self"><br><img alt="873e77af822547405dcafe84be93b302.png" src="examples/media/873e77af822547405dcafe84be93b302.png" target="_self">What's interesting about this approach is the line below.... template.func = 'saveSpreadsheetId'//&lt;-- IMPORTANT... this is what gets called Because it means you can use the FilePickers and Folder pickers etc for different, or multiple purposes.<br>
<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1Zpb3uh2iAsoOaqZekXCEtMB4qbL0rLYQCAxuonq3eRw/edit#gid=0" target="_self">https://docs.google.com/spreadsheets/d/1Zpb3uh2iAsoOaqZekXCEtMB4qbL0rLYQCAxuonq3eRw/edit#gid=0</a>]]></description><link>examples/google-file-pickers-example.html</link><guid isPermaLink="false">Examples/Google File Pickers Example.md</guid><pubDate>Tue, 15 Apr 2025 15:05:50 GMT</pubDate><enclosure url="examples/media/30c37c206d2475b52dc62df4af65a5cb.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/30c37c206d2475b52dc62df4af65a5cb.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Get Members Of Shared Drive]]></title><description><![CDATA[Uses Drive APIJust an example script. We used this for pseudo authentication.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/home/projects/1-G4D6qtc5HW1qx145Bjzr6Wna78dhS8K3xGjTpf9kVo3BIbAVy1v-sVN/edit" target="_self">https://script.google.com/home/projects/1-G4D6qtc5HW1qx145Bjzr6Wna78dhS8K3xGjTpf9kVo3BIbAVy1v-sVN/edit</a>]]></description><link>examples/get-members-of-shared-drive.html</link><guid isPermaLink="false">Examples/Get Members Of Shared Drive.md</guid><pubDate>Tue, 15 Apr 2025 15:05:30 GMT</pubDate></item><item><title><![CDATA[Get Google Tasks]]></title><description><![CDATA[Uses Tasks API.Example script that shows how to get one's Google Tasks.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/home/projects/1NFjU630ttl36uUaxGqNTAbYytD-qMx5gMQf28_8bR39EoAOQz_3roAhf/edit" target="_self">https://script.google.com/home/projects/1NFjU630ttl36uUaxGqNTAbYytD-qMx5gMQf28_8bR39EoAOQz_3roAhf/edit</a>]]></description><link>examples/get-google-tasks.html</link><guid isPermaLink="false">Examples/Get Google Tasks.md</guid><pubDate>Tue, 15 Apr 2025 15:05:20 GMT</pubDate></item><item><title><![CDATA[Get A Document's Comments]]></title><description><![CDATA[<a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/home/projects/1_rMTiW7CvtBaf1xulOzSKSOrTg6dO8U0k5f5llam-3IeQh1ogeyeQLtu/edit" target="_self">https://script.google.com/home/projects/1_rMTiW7CvtBaf1xulOzSKSOrTg6dO8U0k5f5llam-3IeQh1ogeyeQLtu/edit</a>Uses Drive API.This scripts shows how to grab the comments from a Google Document, and save them to sheet.]]></description><link>examples/get-a-document's-comments.html</link><guid isPermaLink="false">Examples/Get A Document's Comments.md</guid><pubDate>Tue, 15 Apr 2025 15:05:13 GMT</pubDate></item><item><title><![CDATA[Forward Emails With Label]]></title><description><![CDATA[This tool lets you forward all your emails - with a particular label, to a named person.<img alt="7d5d6c5e75b3e0228ac7d1c6afb79eb0.png" src="examples/media/7d5d6c5e75b3e0228ac7d1c6afb79eb0.png" target="_self"><br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1VCYPauScjOL3Nq2UkEOSv6bNcOqJwXGowRrFug4YnL8/edit?gid=717297672#gid=717297672" target="_self">https://docs.google.com/spreadsheets/d/1VCYPauScjOL3Nq2UkEOSv6bNcOqJwXGowRrFug4YnL8/edit?gid=717297672#gid=717297672</a>]]></description><link>examples/forward-emails-with-label.html</link><guid isPermaLink="false">Examples/Forward Emails With Label.md</guid><pubDate>Tue, 15 Apr 2025 15:04:58 GMT</pubDate><enclosure url="examples/media/7d5d6c5e75b3e0228ac7d1c6afb79eb0.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/7d5d6c5e75b3e0228ac7d1c6afb79eb0.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Formatting Dates]]></title><description><![CDATA[See a code example here (for UK dates too)<a data-tooltip-position="top" aria-label="https://script.google.com/u/0/home/projects/1sYcYKw-hbsPoE6rDlU3trzzAWaGZWHqqYvetdssNDHs9sHRKL2FW1jEw/edit" rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/u/0/home/projects/1sYcYKw-hbsPoE6rDlU3trzzAWaGZWHqqYvetdssNDHs9sHRKL2FW1jEw/edit" target="_self">https://script.google.com/u/0/home/projects/1sYcYKw-hbsPoE6rDlU3trzzAWaGZWHqqYvetdssNDHs9sHRKL2FW1jEw/edit </a><br>See format string options here <a data-tooltip-position="top" aria-label="https://www.ibm.com/docs/en/iis/11.7?topic=formats-date" rel="noopener nofollow" class="external-link is-unresolved" href="https://www.ibm.com/docs/en/iis/11.7?topic=formats-date" target="_self">https://www.ibm.com/docs/en/​iis/11.7?topic=formats-date</a>&nbsp; var tmz = Session.getScriptTimeZone() var formattedDate = Utilities.formatDate( new Date( ), tmz , "dd/MM/yyyy")
var formattedDateTime = Utilities.formatDate( new Date( ), tmz , "yyyy/MM/dd HH:mm") ]]></description><link>examples/formatting-dates.html</link><guid isPermaLink="false">Examples/Formatting Dates.md</guid><pubDate>Tue, 15 Apr 2025 15:04:49 GMT</pubDate></item><item><title><![CDATA[Folder Copier]]></title><description><![CDATA[<img alt="a974b8b45b7c7a0da006a33e0d8697f8.png" src="examples/media/a974b8b45b7c7a0da006a33e0d8697f8.png" target="_self">This tool creates copies of files and folders in template folder (including subfolders) for a sheet full of people.<br><a data-tooltip-position="top" aria-label="https://sites.google.com/york.ac.uk/foldercopier/home" rel="noopener nofollow" class="external-link is-unresolved" href="https://sites.google.com/york.ac.uk/foldercopier/home" target="_self">https://sites.google.com/york.ac.uk/foldercopier/home </a>]]></description><link>examples/folder-copier.html</link><guid isPermaLink="false">Examples/Folder Copier.md</guid><pubDate>Tue, 15 Apr 2025 15:04:38 GMT</pubDate><enclosure url="examples/media/a974b8b45b7c7a0da006a33e0d8697f8.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/a974b8b45b7c7a0da006a33e0d8697f8.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Export Slides To PNGs]]></title><description><![CDATA[<img alt="82587574489101898bfe6f90fc382f9e.png" src="examples/media/82587574489101898bfe6f90fc382f9e.png" target="_self"><br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/a/macros/york.ac.uk/s/AKfycbwFHtlMUoHMg-jjwKqVJ2ak-yBKJNaw2YdaHYo4uK95AkNewbD_k4s5lGmI5DcaVvFx/exec" target="_self">https://script.google.com/a/macros/york.ac.uk/s/AKfycbwFHtlMUoHMg-jjwKqVJ2ak-yBKJNaw2YdaHYo4uK95AkNewbD_k4s5lGmI5DcaVvFx/exec</a>]]></description><link>examples/export-slides-to-pngs.html</link><guid isPermaLink="false">Examples/Export Slides To PNGs.md</guid><pubDate>Tue, 15 Apr 2025 15:04:21 GMT</pubDate><enclosure url="examples/media/82587574489101898bfe6f90fc382f9e.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/82587574489101898bfe6f90fc382f9e.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Example Regex]]></title><description><![CDATA[How to use regular expressions in Apps Script.See also, this tool is great for "live testing" your regex as you write it.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://regexr.com/84d48" target="_self">https://regexr.com/84d48</a><br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1cGp5jrnCie21mSRce-ZxhuGNPsiQIKIL-Vof1kWScNM/edit?gid=0#gid=0" target="_self">https://docs.google.com/spreadsheets/d/1cGp5jrnCie21mSRce-ZxhuGNPsiQIKIL-Vof1kWScNM/edit?gid=0#gid=0</a>]]></description><link>examples/example-regex.html</link><guid isPermaLink="false">Examples/Example Regex.md</guid><pubDate>Tue, 15 Apr 2025 15:04:07 GMT</pubDate></item><item><title><![CDATA[Example HTML Dialog]]></title><description><![CDATA[<img alt="bb0f0bd4619293a8eaf5ffa7cb47c5be.png" src="examples/media/bb0f0bd4619293a8eaf5ffa7cb47c5be.png" target="_self">This example just shows how to use a HTML dialog in Google Sheets.<br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1AL9_HHOTKHfhvNL8O-koGu4HKrldtmyO9XY34LclSkU/edit#gid=0" target="_self">https://docs.google.com/spreadsheets/d/1AL9_HHOTKHfhvNL8O-koGu4HKrldtmyO9XY34LclSkU/edit#gid=0</a> ]]></description><link>examples/example-html-dialog.html</link><guid isPermaLink="false">Examples/Example HTML Dialog.md</guid><pubDate>Tue, 15 Apr 2025 15:04:00 GMT</pubDate><enclosure url="examples/media/bb0f0bd4619293a8eaf5ffa7cb47c5be.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/bb0f0bd4619293a8eaf5ffa7cb47c5be.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Event Signer Upper]]></title><description><![CDATA[This example assumed there are only 30 seats available to an event.When 30 people sign up to your event, they get an invite and once 30 people have signed up, the Google Form is closed.Interestingly, in this example, it assumes that there are 3 films to choose, and each film can only allow 30 people. So when a particular film is full, it is removed from the options, leaving the other two to fill up.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1cMw2usE8YDzR8D7GZ5zX-Pn5mBkJ6758z1-5vNTIfc8/edit?gid=1339455357#gid=1339455357" target="_self">https://docs.google.com/spreadsheets/d/1cMw2usE8YDzR8D7GZ5zX-Pn5mBkJ6758z1-5vNTIfc8/edit?gid=1339455357#gid=1339455357</a>]]></description><link>examples/event-signer-upper.html</link><guid isPermaLink="false">Examples/Event Signer Upper.md</guid><pubDate>Tue, 15 Apr 2025 15:03:42 GMT</pubDate></item><item><title><![CDATA[Emoji Menus]]></title><description><![CDATA[<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1W-b3SqO7HcuA3bDjqTqvbBVLPAhXFnQ2KedceQPegXI/edit#gid=0" target="_self">https://docs.google.com/spreadsheets/d/1W-b3SqO7HcuA3bDjqTqvbBVLPAhXFnQ2KedceQPegXI/edit#gid=0</a>]]></description><link>examples/emoji-menus.html</link><guid isPermaLink="false">Examples/Emoji Menus.md</guid><pubDate>Tue, 15 Apr 2025 15:03:38 GMT</pubDate></item><item><title><![CDATA[Duration And Time Difference]]></title><description><![CDATA[ // RUN THIS FUNCTION TO SEE IT IN ACTION. function test_timeDifference() { let start = new Date(2021, 5, 8, 9, 0, 0) //9am on the 8th June 2021, today! Logger.log("Start: " + start) let end = new Date() // now Logger.log("End: " + end) let minutes = timeDifference(start, end) Logger.log(minutes + " minutes")
} function timeDifference(startDateTime, endDateTime) { let timeZone = Session.getScriptTimeZone(); //Logger.log( "Timezone: " + timeZone) let difference = endDateTime.getTime() - startDateTime.getTime(); // This will give difference in milliseconds //Look at this way of doing it... let diffDate = new Date( difference)//turn the seconds into a date... //Display the hours and minutes, but not the DATE INFO - that makes no sense! Logger.log("Duration: "+ Utilities.formatDate(diffDate, timeZone, "HH:mm:ss")) //Or you can return the minutes, hours and seconds let resultInMinutes = Math.round(difference / 60000); return resultInMinutes
} ]]></description><link>examples/duration-and-time-difference.html</link><guid isPermaLink="false">Examples/Duration And Time Difference.md</guid><pubDate>Tue, 15 Apr 2025 15:03:28 GMT</pubDate></item><item><title><![CDATA[DuplicateSlides]]></title><description><![CDATA[And remove speaker notes.Sometimes you want to publish a Slides presentation but not share the Presenter's notes.<a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1pD7ZwtLCad8HPh-cuKwWIC424CW9CfAGTuORjPv8Tec/edit?gid=0#gid=0" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1pD7ZwtLCad8HPh-cuKwWIC424CW9CfAGTuORjPv8Tec/edit?gid=0#gid=0" target="_self">Duplicate Slides Sheet</a>The code to do this is below
function syncSlideDecks() { var sourceSlidesDeckId = "1aW1S0AmqnY4u1nBFtS6XyhB9UHR0nfDPbqlj6PoNw3k" //Set this to your source deck var sourceSlideDeck = SlidesApp.openById(sourceSlidesDeckId) var destSlidesDeckId = "1ocAcOdN6l1zrVN6-_dKYY9h6aLEYyOO5_zv63OSobq8" // You need to make this before you sync var destSlidesDeck = SlidesApp.openById( destSlidesDeckId ) //delete all but the first slide - you can't delete all slides of a deck var destSlides = destSlidesDeck.getSlides() for (i=destSlides.length-1; i&gt;0; i--){ Logger.log(`DestSlides: Removing slide ${i}`) var theSlide = destSlides[i] theSlide.remove() } var sourceSlides = sourceSlideDeck.getSlides() for(i=0;i&lt;sourceSlides.length;i++){ var thisSlide = sourceSlides[i] var theNewSlide = destSlidesDeck.appendSlide(thisSlide) theNewSlide.getNotesPage().getSpeakerNotesShape().getText().setText("") Logger.log(`Slide ${i} copied`) } destSlidesDeck.getSlides()[0].remove()//delete the first slide that we left in destSlidesDeck.saveAndClose() Logger.log("Done! " + destSlidesDeck.getUrl()) } ]]></description><link>examples/duplicateslides.html</link><guid isPermaLink="false">Examples/DuplicateSlides.md</guid><pubDate>Tue, 15 Apr 2025 15:03:16 GMT</pubDate></item><item><title><![CDATA[Dependent Dropdown Menus]]></title><description><![CDATA[This script uses onEdit functions to make submenus change depending on what has been chosen.<a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1lLZW2X4dh2FZ2AAcebQEa8isXanG3g0pt8fBqOlyFvE/edit#gid=0" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1lLZW2X4dh2FZ2AAcebQEa8isXanG3g0pt8fBqOlyFvE/edit#gid=0" target="_self">Example Dependent Dropdown Menus</a>]]></description><link>examples/dependent-dropdown-menus.html</link><guid isPermaLink="false">Examples/Dependent Dropdown Menus.md</guid><pubDate>Tue, 15 Apr 2025 15:03:03 GMT</pubDate></item><item><title><![CDATA[Deleting Rows Backwards]]></title><description><![CDATA[If you are looping through a sheet's rows and want to delete some rows, then obv… you are changing what row you are on, if you loop through the rows and delete the row you are on.… Argh! Bugs ahoy!The answer is to loop through the rows backwards, so when you delete row 12, row 13 doesn't get bumped to 12 and things go horribly wrong.
function sheetMaintainance(){ // Actually delete all hidden rows on the To Process sheet... remember to do backwards var values = toProcessSheet.getDataRange().getValues() //get all the data var headers = values.shift() // pop the first row off for (i=values.length-1; i&gt;=0; i--){ // NOTE BACKWARDS LOOP var rowNum = i+2 var isHidden = toProcessSheet.isRowHiddenByUser(rowNum) if (isHidden == true){ Logger.log(`${i} rowNum:${rowNum} isHidden: ${isHidden}`) toProcessSheet.deleteRow(rowNum) Utilities.sleep(300) //Er, slow it down a bit }
} ]]></description><link>examples/deleting-rows-backwards.html</link><guid isPermaLink="false">Examples/Deleting Rows Backwards.md</guid><pubDate>Tue, 15 Apr 2025 15:02:57 GMT</pubDate></item><item><title><![CDATA[Crowdsourcing]]></title><description><![CDATA[This is a full example app to provide a crowd sourcing forum, where people can ask a question and then vote on the answers provided. The questions and answers are saved in a Google Sheet.<img alt="81cb298ca4895465aea30ab38593ac48.png" src="examples/media/81cb298ca4895465aea30ab38593ac48.png" target="_self">Example question and answers.<br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/a/york.ac.uk/macros/s/AKfycbz4c392bUGRE4zf8Mo595l5u-DXU5XLhmgm24DlrGiuGimuVtX6n85RhWKuJlV-4ji1/exec?board=3e821a85-4ed3-4d40-a740-043d3b7a0f84" target="_self">https://script.google.com/a/york.ac.uk/macros/s/AKfycbz4c392bUGRE4zf8Mo595l5u-DXU5XLhmgm24DlrGiuGimuVtX6n85RhWKuJlV-4ji1/exec?board=3e821a85-4ed3-4d40-a740-043d3b7a0f84</a>]]></description><link>examples/crowdsourcing.html</link><guid isPermaLink="false">Examples/Crowdsourcing.md</guid><pubDate>Tue, 15 Apr 2025 15:02:50 GMT</pubDate><enclosure url="examples/media/81cb298ca4895465aea30ab38593ac48.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/81cb298ca4895465aea30ab38593ac48.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Create A Google Map Image]]></title><description><![CDATA[<a data-tooltip-position="top" aria-label="https://script.google.com/home/projects/1S6uY0lxHxLeHzf8FtMqsMFxzoXVnaTzomMxlkGYqlV5z5S3LLaK9__ZW/edit" rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/home/projects/1S6uY0lxHxLeHzf8FtMqsMFxzoXVnaTzomMxlkGYqlV5z5S3LLaK9__ZW/edit" target="_self">This script</a> uses Google Maps API to generate a map image. Probably not usable for 100s + rows of data … ]]></description><link>examples/create-a-google-map-image.html</link><guid isPermaLink="false">Examples/Create A Google Map Image.md</guid><pubDate>Tue, 15 Apr 2025 15:02:32 GMT</pubDate></item><item><title><![CDATA[Concatenate Google Documents]]></title><description><![CDATA[Sometimes, when you have created a collection of Google Documents, in this example a folder full of them, you want to combine them together into one large document.This example reads the files straight from a folder, but you could also create a list of files in a spreadsheet and work that way instead (working this way would mean you could sort and rearrange the order in which the files were added. )Note: See caveat belowfunction combineDocs() { //https://drive.google.com/drive/folders/11_HZjX9kpkD4eOqkf2ZHBwsggx-n6muX var combinedDoc = DocumentApp.create("Combined Document")//Create a new empty document var combinedDocId = combinedDoc.getId() var combinedBody = combinedDoc.getBody() var folder = DriveApp.getFolderById("YOUR_FOLDER_FULL_OF_DOCS_ID_HERE")//Change this var files = folder.getFilesByType("application/vnd.google-apps.document") while (files.hasNext()) { var file = files.next() Logger.log(file.getName()) var combinedDoc = DocumentApp.openById(combinedDocId) var combinedBody = combinedDoc.getBody() var currDoc = DocumentApp.openById(file.getId()) var currBody = currDoc.getBody() var totalElements = currBody.getNumChildren(); for (var j = 0; j &lt; totalElements; ++j) { var element = currBody.getChild(j).copy(); var type = element.getType(); try { if (type == DocumentApp.ElementType.PARAGRAPH) { combinedBody.appendParagraph(element) }else if (type == DocumentApp.ElementType.TABLE){ combinedBody.appendTable(element) }else if (type == DocumentApp.ElementType.LIST_ITEM){ combinedBody.appendListItem(element) }else if (type == DocumentApp.ElementType.INLINE_IMAGE) { var image = element.asInlineImage(); var blob = image.getBlob(); var imageFile = folder.createFile(blob); combinedDoc.getBody().appendImage(imageFile.getBlob()) }else{ Logger.log('Unknown element type: ' + type); } }catch(e){ Logger.log( type + " " + e + " " + e.stack) } } combinedBody.appendPageBreak() combinedDoc.saveAndClose() } Logger.log("Done! " + combinedDoc.getUrl() )
} I have found that if you have a LOT of documents or they have lots of content in each (or complex objects like tables etc), that the code above may "just fail". Google Documents and Apps Script can be far from reliable.Because of this I created this <a data-tooltip-position="top" aria-label="https://colab.research.google.com/drive/19jljuITxJrwHzEAwd0bervEJ2TZ0JVUf?usp=sharing" rel="noopener nofollow" class="external-link is-unresolved" href="https://colab.research.google.com/drive/19jljuITxJrwHzEAwd0bervEJ2TZ0JVUf?usp=sharing" target="_self"> Colab Script</a> - that uses Python to process your uploaded Word.docx files (if your files are in Google Documents - download your folder of documents as Word files) .You can then upload your Word files into the sidebar of this Colab Script and generate a "combined.docx" file - which you could upload back into Google Drive.]]></description><link>examples/concatenate-google-documents.html</link><guid isPermaLink="false">Examples/Concatenate Google Documents.md</guid><pubDate>Tue, 15 Apr 2025 15:02:24 GMT</pubDate></item><item><title><![CDATA[Change Ownership Without A Notification Email]]></title><description><![CDATA[Note: Uses <a data-tooltip-position="top" aria-label="https://developers.google.com/drive/api/reference/rest/v2" rel="noopener nofollow" class="external-link is-unresolved" href="https://developers.google.com/drive/api/reference/rest/v2" target="_self">Drive API</a>
… and I have found v2 to be slightly easier to work with than v3.When creating lots of files for lots of people it may be totally inappropriate to bombard them with the standard Google generated email "So and so has shared a file with you" emails.The workaround is, to use the Drive API and not DriveApp. You can think of Drive API being a little more complicated (for the coder), and also Drive is aimed at coder (as opposed to Scripters like us).The Drive API is for use with Python, Node, PHP, Javascript etc and when you can figure out how it works, with Apps Script. <br><a data-tooltip-position="top" aria-label="https://script.google.com/home/projects/1hoW9zNeX8vLZFCbQE_nyhRDqC3PrIMVJP5AYq1mNKUVrZlLmegWMOJre/edit" rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/home/projects/1hoW9zNeX8vLZFCbQE_nyhRDqC3PrIMVJP5AYq1mNKUVrZlLmegWMOJre/edit" target="_self">This Example Sheet shows how to call Drive API</a> Use the File &gt; Make a copy menu to get your own copy of this sheet.
Remember, you will need to add Drive to your project.<br><img alt="b2c7760e8d46464f66b33e65e8b521dd.png" src="examples/media/b2c7760e8d46464f66b33e65e8b521dd.png" target="_self">]]></description><link>examples/change-ownership-without-a-notification-email.html</link><guid isPermaLink="false">Examples/Change Ownership Without A Notification Email.md</guid><pubDate>Tue, 15 Apr 2025 15:02:14 GMT</pubDate><enclosure url="examples/media/b2c7760e8d46464f66b33e65e8b521dd.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/b2c7760e8d46464f66b33e65e8b521dd.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Close Form When Limit Reached]]></title><description><![CDATA[When you want to create a Google Form sign up for an event, but you only have a certain number of places available, to avoid disappointment it is good idea to write a little AppsScript to close the form when the number of places available have been filled.<a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1FHb_k8PkWQPFLYXCWTyOqUTCYJYDDqgX-9k9PrujdaY/edit#gid=0" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1FHb_k8PkWQPFLYXCWTyOqUTCYJYDDqgX-9k9PrujdaY/edit#gid=0" target="_self">https://script.google.com/home/projects/1TWdM0ZlOXkSdklGXt3byOx5F9AlxopqgOQ3yRxJgBm0Wz_3CfAOlkBj1/edit</a><br>This example uses <a data-tooltip-position="top" aria-label="https://developers.google.com/apps-script/reference/calendar/calendar-app" rel="noopener nofollow" class="external-link is-unresolved" href="https://developers.google.com/apps-script/reference/calendar/calendar-app" target="_self">CalendarApp</a> to invite people who sign up using a Google Form to a particular event.<br><a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1FHb_k8PkWQPFLYXCWTyOqUTCYJYDDqgX-9k9PrujdaY/edit#gid=0" target="_self">https://docs.google.com/spreadsheets/d/1FHb_k8PkWQPFLYXCWTyOqUTCYJYDDqgX-9k9PrujdaY/edit#gid=0</a>Because initially, working out what the eventIds of your Calendar events isn't entirely straightforward, this code has this useful function to help you.function test_listEvents() { /* Because it's difficult to get eventId it's best to just list all the events on a particular day and fish the eventId out. Run this function */ var startDate = new Date(2024, 5, 10) listEvents(startDate)
} function listEvents(startDate) { // Change this to your calendar Id -&gt; available in the calendar's settings var calendarId = "c_2cbcdb9fbd1c950e01a0701271fd60e9316e80b7bf3bc69f9a19850d0fec3205@group.calendar.google.com" var cal = CalendarApp.getCalendarById(calendarId) Logger.log("Calendar: " + cal.getName()) var events = cal.getEventsForDay(startDate) for (i = 0; i &lt; events.length; i++) { var event = events[i] Logger.log(event.getId() + " " + event.getTitle()) } } ]]></description><link>examples/close-form-when-limit-reached.html</link><guid isPermaLink="false">Examples/Close Form When Limit Reached.md</guid><pubDate>Tue, 15 Apr 2025 15:02:03 GMT</pubDate></item><item><title><![CDATA[Automatically Coloured Ids]]></title><description><![CDATA[The need for this arose from having to work with lots of columns and rows of data in a Sheet. We were generating hex ids for each row so thought it might be a good idea, interface wise, to colour the rows based on the hex id, for example, "bcdb58".This created a slight problem in that the colours generated were completely random, making the text at time illegible. For example the colour, or hex id generated might be equivalent to deep purple.<img alt="7964a52b6a337717dbcfda7f3696dc59.png" src="examples/media/7964a52b6a337717dbcfda7f3696dc59.png" target="_self"><br><a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1Pin8IiYfq8gMmmddd1eEIImlKVLBzmjocRaLTIqPSoc/edit?pli=1#gid=0" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1Pin8IiYfq8gMmmddd1eEIImlKVLBzmjocRaLTIqPSoc/edit?pli=1#gid=0" target="_self">Automatically Coloured Ids</a>To fix this, I added some code that pastelifys the colours by lightening them. This achieved the UI we wanted, to make the rows easier to keep your eye on as you scroll left to right.]]></description><link>examples/automatically-coloured-ids.html</link><guid isPermaLink="false">Examples/Automatically Coloured Ids.md</guid><pubDate>Tue, 15 Apr 2025 15:01:14 GMT</pubDate><enclosure url="examples/media/7964a52b6a337717dbcfda7f3696dc59.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/7964a52b6a337717dbcfda7f3696dc59.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Audio Recorder]]></title><description><![CDATA[<img alt="32b1c635d4953d435f3e7421e6438c3b.png" src="examples/media/32b1c635d4953d435f3e7421e6438c3b.png" target="_self">This is an example web app written in AppsScript that records your spoken audio and transcribes the text of what you have said. The transcription isn't "the best in the world" but it's quite good and the audio files are saved to your Google Drive as .wav files.<br>Try the <a data-tooltip-position="top" aria-label="https://script.google.com/a/macros/york.ac.uk/s/AKfycbzTM4d8do-nX3n7-K0fOJB48G9cU9f1075XM4f6bHzOUSGDgen5D0ch7GsWfoMjM9fhJA/exec" rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/a/macros/york.ac.uk/s/AKfycbzTM4d8do-nX3n7-K0fOJB48G9cU9f1075XM4f6bHzOUSGDgen5D0ch7GsWfoMjM9fhJA/exec" target="_self">Recording App here</a>.<br><a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1bVccVdeg6WqYDSeg7O_2n8SqsGD8dntdUrs0pUBH1Es/edit?gid=0#gid=0" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1bVccVdeg6WqYDSeg7O_2n8SqsGD8dntdUrs0pUBH1Es/edit?gid=0#gid=0" target="_self">Sheet and Apps Script here</a><br>Within this AppsScript web app is a <a data-tooltip-position="top" aria-label="https://p5js.org/" rel="noopener nofollow" class="external-link is-unresolved" href="https://p5js.org/" target="_self">p5js sketch</a>, that connects to the mic, records audio, displaying the wave form, and sends the saved data to the AppsScript backend (where the recording is saved to Google Drive and logged in a Spreadsheet.)]]></description><link>examples/audio-recorder.html</link><guid isPermaLink="false">Examples/Audio Recorder.md</guid><pubDate>Tue, 15 Apr 2025 15:00:58 GMT</pubDate><enclosure url="examples/media/32b1c635d4953d435f3e7421e6438c3b.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/32b1c635d4953d435f3e7421e6438c3b.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Anonymous Functions]]></title><description><![CDATA[Anonymous functions are a feature of javascript that means you can save a function, ready to run, so to speak with parameters already set.Why is this useful? Well, it means you might have one makeMeal( whichMeal ) functions and be able to save three pre-filled functions like this …[
{name:"Breakfast", time:08:00, action: makeMeal("Continental Breakfast")},
{name:"Lunch", time:13:00, action: makeMeal("Egg Sandwich")},
{name:"Dinner", time:19:00, action: makeMeal("Sausage and Mash")},
]... which is quite clever because it means we only need to write and maintain one function (not three) and we don't have to change code to add a new meal, like "Elevenses" for example… <img alt="72fafa3e95ec42c1ec1df6cb12d52363.png" src="examples/media/72fafa3e95ec42c1ec1df6cb12d52363.png" target="_self"><br><a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/159dxHuu5bvIyD8-PXDypztBVGOKoNWF01FyPHoA3XYs/edit#gid=848256442" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/159dxHuu5bvIyD8-PXDypztBVGOKoNWF01FyPHoA3XYs/edit#gid=848256442" target="_self">Sheet example here</a>
In the example above, the Sheet's Menu items are created based on the data in the sheet.While they are really useful, it's worth mentioning that the code for your anonymous functions needs to the run at the start of every this your code ever does. Basically, it needs to loaded, always - so if you want to do lots of work in the code to make your anonymous functions, it will impact the speed of all your other anonymous functions. I was using them in combination with onEdit(e) functions, which in retrospect I think was a bad idea.Anonymous functions also mess with your debugging because you are thrown into a sort of new debug-space. I can't think of a better way to describe it.<br>See if reading this <a data-tooltip-position="top" aria-label="https://www.geeksforgeeks.org/javascript-anonymous-functions/" rel="noopener nofollow" class="external-link is-unresolved" href="https://www.geeksforgeeks.org/javascript-anonymous-functions/" target="_self">explanation of anonymous functions </a>makes more sense than I did.]]></description><link>examples/anonymous-functions.html</link><guid isPermaLink="false">Examples/Anonymous Functions.md</guid><pubDate>Tue, 15 Apr 2025 15:00:30 GMT</pubDate><enclosure url="examples/media/72fafa3e95ec42c1ec1df6cb12d52363.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/72fafa3e95ec42c1ec1df6cb12d52363.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[AI Dashboard Webapp]]></title><description><![CDATA[I needed a way to present a list of data (a collection of generative AI I'd used) in a more interesting manner than a spreadsheet. In this example, a p5js project reads the data from an Apps Script web app API.<a data-tooltip-position="top" aria-label="https://static.everythingability.opalstacked.com/ai-dashboard/" rel="noopener nofollow" class="external-link is-unresolved" href="https://static.everythingability.opalstacked.com/ai-dashboard/" target="_self">The AI Dashboard</a><br><img alt="2843b017faa05f8a1b8883fa27f79e09.png" src="examples/media/2843b017faa05f8a1b8883fa27f79e09.png" target="_self"><br>This project ( <a data-tooltip-position="top" aria-label="https://docs.google.com/spreadsheets/d/1Xhg-vBwsWpLWW_bC7U27KsUkEo63bfEVRGt4WmnbbUs/edit#gid=0" rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1Xhg-vBwsWpLWW_bC7U27KsUkEo63bfEVRGt4WmnbbUs/edit#gid=0" target="_self">Spreadsheet here</a> ) is an example of how to create a JSON API. In this case, the JSON is used by a <a data-tooltip-position="top" aria-label="https://editor.p5js.org/tom.smith/sketches/P885tlA_9" rel="noopener nofollow" class="external-link is-unresolved" href="https://editor.p5js.org/tom.smith/sketches/P885tlA_9" target="_self">p5js project</a>. All it does is display a sheet's worth of data in a prettier manner. Note: It uses my <a data-href="Objectify" href="examples/objectify.html" class="internal-link" target="_self" rel="noopener nofollow">Objectify</a> function to turn an array of arrays into a an array of objects.function doGet(e) { var ss = SpreadsheetApp.getActiveSpreadsheet() var sheet = ss.getSheetByName("Sheet1") var values = sheet.getDataRange().getValues() var headers = values.shift() var objArray = objectify(headers, values) var json = JSON.stringify(objArray) return ContentService.createTextOutput(json).setMimeType(ContentService.MimeType.JSON) }
When you add a doGet function to your code, and Deploy it, you have created a mini web server. The URL to this example web server is ...[https://script.google.com/macros/s/AKfycbx04XjgKr12_gqN_Okq1prGAKYieX5YUpwSvsO3T8zXJzb2SgVbqOEi3_OnraGeX72sWw/exec]...and it returns JSON data, a list of object, which begins like this…[ { "name": "NightCafe", "category": "image", "url": "https://creator.nightcafe.studio/", "about": "free if you visit daily to build up credits", "src": "https://aitools.fyi/_next/image?url=https%3A%2F%2Fassets.aitools.fyi%2Fts%2F129.jpg&amp;w=3840&amp;q=75" }, { "name": "Ideogram", "category": "image", "url": "https://ideogram.ai/", "about": "Good with text", "src": "https://static.everythingability.opalstacked.com/Ideogram__Helping_people_become_more_creative_.png" }, { "name": "Leonardo.ai", "category": "image", "url": "https://app.leonardo.ai/", "about": "free quota, buy more", "src": "https://static.everythingability.opalstacked.com/leonardo.jpeg" },
]
You might notice that the above Apps Script code calls a function called objectify. I use this chunk of code lots, and it turns a list of lists into a list of javascript objects, which I find much more useful than regular arrays.//turns a list of [["Tom", "Gray", etc],[]] into a list of named [{name:"Tom", hair:"Grey", etc}]
function objectify(headers, rows){ var newarray=[] var obj for(var y = 0; y &lt; rows.length; y++){ obj = {}; for(var i = 0; i &lt; headers.length; i++){ obj[headers[i]] = rows[y][i]; } newarray.push(obj) } return newarray }
The p5js sketch, then takes this JSON list of AI tools and simply makes the web page by iterating through the list and adding them in. The sketch has the CSS and HTML it needs to "make it look nice".This is the bit of code that runs in the browser (unlike Apps Script), and gets the JSON, and makes the web page.
// NEED TO CHANGE THIS TO MATCH YOUR WEB APP'S URL
var publishedWebAppURL = 'https://script.google.com/macros/s/AKfycbx04XjgKr12_gqN_Okq1prGAKYieX5YUpwSvsO3T8zXJzb2SgVbqOEi3_OnraGeX72sWw/exec' let items = []
//Used for clouds effect, not needed but fun...
let panels = [];
let index = 0;
const num = 32; //buffer size let dsize = 0;
let step = 0;
let done = false;
//End used for clouds effect let categoryHTML = `&lt;div class="grid-container"&gt;` function addItemsToDom(items){ var mainEle = select('main') let wrapDiv = createDiv('') wrapDiv.addClass('grid-container') wrapDiv.parent(mainEle) for (i=0;i&lt;items.length; i++){ var item = items[i] var name = item.name print(item.name) var about = item.about var src = item.src var url = item.url var category = item.category let itemDiv = createDiv(`&lt;div class="grid-item ${category}" onclick="window.open('${url}','_blank');" style="cursor: pointer;"&gt; &lt;img src="${src}" /&gt; &lt;p&gt;${name}&lt;/p&gt; ${about} &lt;/div&gt;`); wrapDiv.child(itemDiv) } }
function preload(){ items = loadJSON(publishedWebAppURL, addItemsToDom)
} function windowResized() { //console.log('resized'); resizeCanvas(displayWidth, displayHeight);
} function setup() { canvas = createCanvas(displayWidth, displayHeight); canvas.position(0, 0); canvas.style('z-index', '-1'); dsize = height / 40; for (let i = 0; i &lt; num; i++) { panels.push(createGraphics(displayWidth, displayHeight)); } //setTimeout(bufferPanels, 1000); } function draw() { if (done) { image(panels[index], 0, 0); //updatePanel(index); //if you comment this out, you get 60 fps on the buffered frames, but they repeat! :( //index = (index + 1) % panels.length; } else { //background('#e27897'); }
} /*
function bufferPanels() { for (let i = 0; i &lt; panels.length; i++) { updatePanel(i); } done = true;
} function updatePanel(p) { panels[p].noStroke(); panels[p].background('#ed17c6'); for (let x = 0; x &lt;= width; x += dsize) { for (let y = 0; y &lt;= height; y += dsize) { let r = noise((x / (width / 2)) + step / 200, (y / (height / 2)) + step / 200, step / 200) * 400; panels[p].fill(r, 120, 150, 240); panels[p].ellipse(x, y, 4 * dsize); } } step += 1;
}*/ ]]></description><link>examples/ai-dashboard-webapp.html</link><guid isPermaLink="false">Examples/AI Dashboard Webapp.md</guid><pubDate>Tue, 15 Apr 2025 14:59:18 GMT</pubDate><enclosure url="examples/media/2843b017faa05f8a1b8883fa27f79e09.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/2843b017faa05f8a1b8883fa27f79e09.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[TextFinder Search and Postcode DB]]></title><description><![CDATA[<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1TLjbQ3IHWKx21DiY66J06jQgfAzTEgplqV_1dfxFsW8/edit?gid=187492155#gid=187492155" target="_self">https://docs.google.com/spreadsheets/d/1TLjbQ3IHWKx21DiY66J06jQgfAzTEgplqV_1dfxFsW8/edit?gid=187492155#gid=187492155</a>]]></description><link>examples/textfinder-search-and-postcode-db.html</link><guid isPermaLink="false">Examples/TextFinder Search and Postcode DB.md</guid><pubDate>Fri, 28 Feb 2025 16:52:38 GMT</pubDate></item><item><title><![CDATA[612d47db27a26c39b15ad95d14eb1a8e]]></title><description><![CDATA[<img src="examples/media/612d47db27a26c39b15ad95d14eb1a8e.png" target="_self">]]></description><link>examples/media/612d47db27a26c39b15ad95d14eb1a8e.html</link><guid isPermaLink="false">Examples/media/612d47db27a26c39b15ad95d14eb1a8e.png</guid><pubDate>Thu, 31 Oct 2024 09:30:54 GMT</pubDate><enclosure url="examples/media/612d47db27a26c39b15ad95d14eb1a8e.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/612d47db27a26c39b15ad95d14eb1a8e.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Approval Using Prefilled URL]]></title><description><![CDATA[This example is from a Conference Travel Approval Form I worked on with Kirsty. It uses a simple editURL to make people edit the same Form, but with sections that each different approver fills in.
/*
Runs from a trigger when the Form is submitted
Collects Form values and calls a second function to create and send an email using these values
Trigger only activates IF cell in column 19 of that row is empty. Otherwise nothing happens
*/ function onFormSubmit(e) { //get relevant sheet (will need to write URL to response sheet later) var ss = SpreadsheetApp.getActiveSpreadsheet(); var responseSheet = ss.getSheetByName('Form responses 1'); var rowNum = e.range.getRow(); var formValues = e.namedValues;//collect Form values (this will be an array) //************ get individual Form values for use in email ************ var approver1EmailAddress = formValues['Approver 1 Email Address'][0]; var approver1Name = formValues['Approver 1 Name'][0]; var approver2EmailAddress = formValues['Approver 2 Email Address'][0]; var approver2Name = formValues['Approver 2 Name'][0]; var approver3EmailAddress = formValues['Approver 3 Email Address'][0]; var approver3Name = formValues['Approver 3 Name'][0]; var travellerName = formValues['Name of Traveller/Requester '][0]; var conference = formValues['Name of Conference'][0]; var conferenceStart = formValues['Start Date of Conference'][0]; var conferenceEnd = formValues['End Date of Conference'][0]; Logger.log( `${approver1EmailAddress}: "${approver1Name}" - "${travellerName}" - ${conferenceStart}/${conferenceEnd}` ) var editURL = getEditURLForRow(rowNum, "Form responses 1") responseSheet.getRange("AF" + rowNum ).setValue( editURL ) // Sends the email to Approver1 sendEmail(approver1EmailAddress,travellerName, approver1Name, conference, conferenceStart, conferenceEnd , editURL ) responseSheet.getRange("AG" + rowNum ).setValue( `${approver1EmailAddress} email sent` ).setNote( new Date() ) //Send to additional Approvers if (approver2EmailAddress != ''){ sendEmail(approver2EmailAddress,travellerName, approver2Name, conference, conferenceStart, conferenceEnd , editURL ) //append to AG responseSheet.getRange("AG" + rowNum ).setValue(responseSheet.getRange("AG" + rowNum ).getValue() +`, ${approver3EmailAddress} email sent`) } if (approver3EmailAddress != ''){ sendEmail(approver3EmailAddress,travellerName, approver3Name, conference, conferenceStart, conferenceEnd , editURL ) responseSheet.getRange("AG" + rowNum ).setValue(responseSheet.getRange("AG" + rowNum ).getValue() + `, ${approver3EmailAddress} email sent`) } Logger.log("Completed") } /**
The ONLY way this will work is if you get the values from the SHEET, not from formValues.
This is because the timestamps match, and are dates already, whereas formResponses and strings. Nightmare.
*/ function test_getEditURLForRow(){ var rowNum = 5 //Change this and run it. It doesn't write anything to sheet. Logger.log( getEditURLForRow(rowNum, "Form responses 1") )
} function getEditURLForRow(rowNum, sheetName) { //get relevant sheet (will need to write URL to response sheet later) var ss = SpreadsheetApp.getActiveSpreadsheet(); //This spreasheet... var responseSheet = ss.getSheetByName(sheetName); var rowValues = responseSheet.getRange(rowNum, 1, 1, responseSheet.getLastRow() ).getValues()[0] //Logger.log(rowValues) var timestamp = rowValues[0] //Logger.log(timestamp + " " + typeof timestamp) var editURL = getEditURL(ss, timestamp) return editURL } function getEditURL(ss, timestamp){ var formURL = ss.getFormUrl() //Logger.log( timestamp + " " + typeof timestamp) var form = FormApp.openByUrl(formURL) var formResponses = form.getResponses(); formResponses.reverse() // Let's work backwards through the list, should get there quicker... for (var i = 0; i &lt; formResponses.length; i++) { var formResponse = formResponses[i]; var response_timestamp = formResponse.getTimestamp() //Logger.log( Number(timestamp) + " " + Number(response_timestamp) ) if ( Number(timestamp) == Number(response_timestamp)){ var url = formResponse.getEditResponseUrl() return url break } } Logger.log( "Error: No edit URL found") //If it gets to here, it ain't found the formResponse. Boo!
} function sendEmail(emailAddress,travellerName,approverName, conference, conferenceStart, conferenceEnd , editURL ) { //Create a HTML template and add variables to it var template = HtmlService.createTemplateFromFile('Email') template.travellerName = travellerName template.approverName = approverName template.conference = conference template.conferenceStart = conferenceStart template.conferenceEnd = conferenceEnd template.editURL = editURL var html = template.evaluate().getContent() // send email Logger.log("Sending to:" + emailAddress) var subject = 'Travel budget approval - ' + travellerName; var options = {bcc: 'kirsty.adegboro@york.ac.uk', htmlBody:html, noReply:true,}; MailApp.sendEmail(emailAddress, subject, "", options); } ]]></description><link>examples/approval-using-prefilled-url.html</link><guid isPermaLink="false">Examples/Approval Using Prefilled URL.md</guid><pubDate>Thu, 17 Oct 2024 12:19:05 GMT</pubDate></item><item><title><![CDATA[Sync Slides]]></title><description><![CDATA[This can be used two ways.A. If you want to just make a copy of a Slides deck without presenter notes, enter the URL to your slides and use the Admin menu.
B. If you want to sync slides to a particular Slides deck, create the destination Slides deck first, and enter both URLs.<a rel="noopener nofollow" class="external-link is-unresolved" href="https://docs.google.com/spreadsheets/d/1hGyxjdAI9SSKCI86Qy0HiS_1uuVaA2uU1oyfVfh0-ts/edit?gid=0#gid=0" target="_self">https://docs.google.com/spreadsheets/d/1hGyxjdAI9SSKCI86Qy0HiS_1uuVaA2uU1oyfVfh0-ts/edit?gid=0#gid=0</a>]]></description><link>examples/sync-slides.html</link><guid isPermaLink="false">Examples/Sync Slides.md</guid><pubDate>Tue, 15 Oct 2024 13:10:16 GMT</pubDate></item><item><title><![CDATA[Unique Id Generation]]></title><description><![CDATA[When people fill out a Google Form, give them a uniqueID. Often people want recognisable ids, for example stu_67832 or staff_723184 or whatever. <a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/u/0/home/projects/1ftfPLr5ODscREOAOq3tMDDnBP0KeORGQ3KTe13JYO550SQQXnSW-CTfO/edit" target="_self">https://script.google.com/u/0/home/projects/1ftfPLr5ODscREOAOq3tMDDnBP0KeORGQ3KTe13JYO550SQQXnSW-CTfO/edit</a>]]></description><link>examples/unique-id-generation.html</link><guid isPermaLink="false">Examples/Unique Id Generation.md</guid><pubDate>Fri, 11 Oct 2024 13:42:36 GMT</pubDate></item><item><title><![CDATA[f5c22d0cee1bf74a25fe1e4a5c0b233e]]></title><description><![CDATA[<img src="examples/media/f5c22d0cee1bf74a25fe1e4a5c0b233e.png" target="_self">]]></description><link>examples/media/f5c22d0cee1bf74a25fe1e4a5c0b233e.html</link><guid isPermaLink="false">Examples/media/f5c22d0cee1bf74a25fe1e4a5c0b233e.png</guid><pubDate>Fri, 11 Oct 2024 13:34:16 GMT</pubDate><enclosure url="examples/media/f5c22d0cee1bf74a25fe1e4a5c0b233e.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/f5c22d0cee1bf74a25fe1e4a5c0b233e.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Send HTML Email]]></title><description><![CDATA[Note: Apps Script's template service is a really good way to build an HTML email.
Editing a single HTML file is often WAY easier than trying to build some HTML out of variables and the + operator.
With a little training people can learn how to ‘dodge’ the HTML and edit these files when they need updating (perhaps changing the year name etc) function sendHTMLEmail(recipientEmail,recipientName, subject, message, videoID) { const regex = /\n/gi; var message = message.replaceAll(regex , "&lt;br&gt;") // turn returns into double line breaks var template = HtmlService.createTemplateFromFile('Email') template.recipientName = recipientName template.message = message template.videoID = videoID //didn't work, don't use it now var html = template.evaluate().getContent() MailApp.sendEmail( recipientEmail, subject, "", {htmlBody: html}) } Example using a Template to search and replace the template file with your data, to personalise an email. So, for example, note how easy the HTML is to create template tags. Incidentally, these template files can also contain Apps Script you might have some logic coded into your templates, that maybe personalised it further based on your data.
&lt;p&gt;Dear &lt;?= recipientName ?&gt;,&lt;/p&gt; &lt;?!= message ?&gt; &lt;!-- note the exclamation mark, that means allow HTML in the variable --&gt; <a rel="noopener nofollow" class="external-link is-unresolved" href="https://script.google.com/u/0/home/projects/1Bo-4Tlx9kK4ODPBiX6SlMSOv6_w5vEnU-H1xHWGJuBaqka-LBDNtHfSh/edit" target="_self">https://script.google.com/u/0/home/projects/1Bo-4Tlx9kK4ODPBiX6SlMSOv6_w5vEnU-H1xHWGJuBaqka-LBDNtHfSh/edit</a>]]></description><link>examples/send-html-email.html</link><guid isPermaLink="false">Examples/Send HTML Email.md</guid><pubDate>Fri, 11 Oct 2024 13:30:03 GMT</pubDate></item><item><title><![CDATA[8cfe952a80944acaf7871e128e9eefb8]]></title><description><![CDATA[<img src="examples/media/8cfe952a80944acaf7871e128e9eefb8.png" target="_self">]]></description><link>examples/media/8cfe952a80944acaf7871e128e9eefb8.html</link><guid isPermaLink="false">Examples/media/8cfe952a80944acaf7871e128e9eefb8.png</guid><pubDate>Fri, 11 Oct 2024 13:08:58 GMT</pubDate><enclosure url="examples/media/8cfe952a80944acaf7871e128e9eefb8.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/8cfe952a80944acaf7871e128e9eefb8.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[96ea0e1db255b3020637bd643b3107cd]]></title><description><![CDATA[<img src="examples/media/96ea0e1db255b3020637bd643b3107cd.png" target="_self">]]></description><link>examples/media/96ea0e1db255b3020637bd643b3107cd.html</link><guid isPermaLink="false">Examples/media/96ea0e1db255b3020637bd643b3107cd.png</guid><pubDate>Fri, 11 Oct 2024 11:49:05 GMT</pubDate><enclosure url="examples/media/96ea0e1db255b3020637bd643b3107cd.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/96ea0e1db255b3020637bd643b3107cd.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[1137f602f33424c96992c8354b81241a]]></title><description><![CDATA[<img src="examples/media/1137f602f33424c96992c8354b81241a.png" target="_self">]]></description><link>examples/media/1137f602f33424c96992c8354b81241a.html</link><guid isPermaLink="false">Examples/media/1137f602f33424c96992c8354b81241a.png</guid><pubDate>Fri, 11 Oct 2024 11:47:51 GMT</pubDate><enclosure url="examples/media/1137f602f33424c96992c8354b81241a.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/1137f602f33424c96992c8354b81241a.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[873e77af822547405dcafe84be93b302]]></title><description><![CDATA[<img src="examples/media/873e77af822547405dcafe84be93b302.png" target="_self">]]></description><link>examples/media/873e77af822547405dcafe84be93b302.html</link><guid isPermaLink="false">Examples/media/873e77af822547405dcafe84be93b302.png</guid><pubDate>Fri, 11 Oct 2024 11:37:37 GMT</pubDate><enclosure url="examples/media/873e77af822547405dcafe84be93b302.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/873e77af822547405dcafe84be93b302.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[30c37c206d2475b52dc62df4af65a5cb]]></title><description><![CDATA[<img src="examples/media/30c37c206d2475b52dc62df4af65a5cb.png" target="_self">]]></description><link>examples/media/30c37c206d2475b52dc62df4af65a5cb.html</link><guid isPermaLink="false">Examples/media/30c37c206d2475b52dc62df4af65a5cb.png</guid><pubDate>Fri, 11 Oct 2024 11:36:17 GMT</pubDate><enclosure url="examples/media/30c37c206d2475b52dc62df4af65a5cb.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/30c37c206d2475b52dc62df4af65a5cb.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[7d5d6c5e75b3e0228ac7d1c6afb79eb0]]></title><description><![CDATA[<img src="examples/media/7d5d6c5e75b3e0228ac7d1c6afb79eb0.png" target="_self">]]></description><link>examples/media/7d5d6c5e75b3e0228ac7d1c6afb79eb0.html</link><guid isPermaLink="false">Examples/media/7d5d6c5e75b3e0228ac7d1c6afb79eb0.png</guid><pubDate>Fri, 11 Oct 2024 11:27:04 GMT</pubDate><enclosure url="examples/media/7d5d6c5e75b3e0228ac7d1c6afb79eb0.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/7d5d6c5e75b3e0228ac7d1c6afb79eb0.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[a974b8b45b7c7a0da006a33e0d8697f8]]></title><description><![CDATA[<img src="examples/media/a974b8b45b7c7a0da006a33e0d8697f8.png" target="_self">]]></description><link>examples/media/a974b8b45b7c7a0da006a33e0d8697f8.html</link><guid isPermaLink="false">Examples/media/a974b8b45b7c7a0da006a33e0d8697f8.png</guid><pubDate>Fri, 11 Oct 2024 11:23:49 GMT</pubDate><enclosure url="examples/media/a974b8b45b7c7a0da006a33e0d8697f8.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/a974b8b45b7c7a0da006a33e0d8697f8.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[82587574489101898bfe6f90fc382f9e]]></title><description><![CDATA[<img src="examples/media/82587574489101898bfe6f90fc382f9e.png" target="_self">]]></description><link>examples/media/82587574489101898bfe6f90fc382f9e.html</link><guid isPermaLink="false">Examples/media/82587574489101898bfe6f90fc382f9e.png</guid><pubDate>Fri, 11 Oct 2024 11:11:15 GMT</pubDate><enclosure url="examples/media/82587574489101898bfe6f90fc382f9e.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/82587574489101898bfe6f90fc382f9e.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[bb0f0bd4619293a8eaf5ffa7cb47c5be]]></title><description><![CDATA[<img src="examples/media/bb0f0bd4619293a8eaf5ffa7cb47c5be.png" target="_self">]]></description><link>examples/media/bb0f0bd4619293a8eaf5ffa7cb47c5be.html</link><guid isPermaLink="false">Examples/media/bb0f0bd4619293a8eaf5ffa7cb47c5be.png</guid><pubDate>Fri, 11 Oct 2024 11:05:10 GMT</pubDate><enclosure url="examples/media/bb0f0bd4619293a8eaf5ffa7cb47c5be.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/bb0f0bd4619293a8eaf5ffa7cb47c5be.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[74bfde0bf6a3e22b6c45431c720ea465]]></title><description><![CDATA[<img src="examples/media/74bfde0bf6a3e22b6c45431c720ea465.png" target="_self">]]></description><link>examples/media/74bfde0bf6a3e22b6c45431c720ea465.html</link><guid isPermaLink="false">Examples/media/74bfde0bf6a3e22b6c45431c720ea465.png</guid><pubDate>Tue, 08 Oct 2024 12:55:46 GMT</pubDate><enclosure url="examples/media/74bfde0bf6a3e22b6c45431c720ea465.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/74bfde0bf6a3e22b6c45431c720ea465.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[81cb298ca4895465aea30ab38593ac48]]></title><description><![CDATA[<img src="examples/media/81cb298ca4895465aea30ab38593ac48.png" target="_self">]]></description><link>examples/media/81cb298ca4895465aea30ab38593ac48.html</link><guid isPermaLink="false">Examples/media/81cb298ca4895465aea30ab38593ac48.png</guid><pubDate>Tue, 08 Oct 2024 12:47:43 GMT</pubDate><enclosure url="examples/media/81cb298ca4895465aea30ab38593ac48.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/81cb298ca4895465aea30ab38593ac48.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[b2c7760e8d46464f66b33e65e8b521dd]]></title><description><![CDATA[<img src="examples/media/b2c7760e8d46464f66b33e65e8b521dd.png" target="_self">]]></description><link>examples/media/b2c7760e8d46464f66b33e65e8b521dd.html</link><guid isPermaLink="false">Examples/media/b2c7760e8d46464f66b33e65e8b521dd.png</guid><pubDate>Tue, 08 Oct 2024 10:53:20 GMT</pubDate><enclosure url="examples/media/b2c7760e8d46464f66b33e65e8b521dd.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/b2c7760e8d46464f66b33e65e8b521dd.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[7964a52b6a337717dbcfda7f3696dc59]]></title><description><![CDATA[<img src="examples/media/7964a52b6a337717dbcfda7f3696dc59.png" target="_self">]]></description><link>examples/media/7964a52b6a337717dbcfda7f3696dc59.html</link><guid isPermaLink="false">Examples/media/7964a52b6a337717dbcfda7f3696dc59.png</guid><pubDate>Tue, 08 Oct 2024 10:38:56 GMT</pubDate><enclosure url="examples/media/7964a52b6a337717dbcfda7f3696dc59.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/7964a52b6a337717dbcfda7f3696dc59.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[72fafa3e95ec42c1ec1df6cb12d52363]]></title><description><![CDATA[<img src="examples/media/72fafa3e95ec42c1ec1df6cb12d52363.png" target="_self">]]></description><link>examples/media/72fafa3e95ec42c1ec1df6cb12d52363.html</link><guid isPermaLink="false">Examples/media/72fafa3e95ec42c1ec1df6cb12d52363.png</guid><pubDate>Tue, 08 Oct 2024 10:28:45 GMT</pubDate><enclosure url="examples/media/72fafa3e95ec42c1ec1df6cb12d52363.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/72fafa3e95ec42c1ec1df6cb12d52363.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[32b1c635d4953d435f3e7421e6438c3b]]></title><description><![CDATA[<img src="examples/media/32b1c635d4953d435f3e7421e6438c3b.png" target="_self">]]></description><link>examples/media/32b1c635d4953d435f3e7421e6438c3b.html</link><guid isPermaLink="false">Examples/media/32b1c635d4953d435f3e7421e6438c3b.png</guid><pubDate>Tue, 17 Sep 2024 10:02:25 GMT</pubDate><enclosure url="examples/media/32b1c635d4953d435f3e7421e6438c3b.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/32b1c635d4953d435f3e7421e6438c3b.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[Sheet's Rows To Document with Template]]></title><link>examples/sheet's-rows-to-document-with-template.html</link><guid isPermaLink="false">Examples/Sheet's Rows To Document with Template.md</guid><pubDate>Wed, 17 Jul 2024 11:28:00 GMT</pubDate></item><item><title><![CDATA[2843b017faa05f8a1b8883fa27f79e09]]></title><description><![CDATA[<img src="examples/media/2843b017faa05f8a1b8883fa27f79e09.png" target="_self">]]></description><link>examples/media/2843b017faa05f8a1b8883fa27f79e09.html</link><guid isPermaLink="false">Examples/media/2843b017faa05f8a1b8883fa27f79e09.png</guid><pubDate>Fri, 12 Jul 2024 14:33:07 GMT</pubDate><enclosure url="examples/media/2843b017faa05f8a1b8883fa27f79e09.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/2843b017faa05f8a1b8883fa27f79e09.png"&gt;&lt;/figure&gt;</content:encoded></item><item><title><![CDATA[9f734ac23d950c8a43b4a22b86b35e84]]></title><description><![CDATA[<img src="examples/media/9f734ac23d950c8a43b4a22b86b35e84.png" target="_self">]]></description><link>examples/media/9f734ac23d950c8a43b4a22b86b35e84.html</link><guid isPermaLink="false">Examples/media/9f734ac23d950c8a43b4a22b86b35e84.png</guid><pubDate>Tue, 11 Jun 2024 10:13:32 GMT</pubDate><enclosure url="examples/media/9f734ac23d950c8a43b4a22b86b35e84.png" length="0" type="image/png"/><content:encoded>&lt;figure&gt;&lt;img src="examples/media/9f734ac23d950c8a43b4a22b86b35e84.png"&gt;&lt;/figure&gt;</content:encoded></item></channel></rss>