Skip to content
Skip to chapter

Chapter 15

Workspace services

Generate a Docs report from a template

Use DocumentApp and DriveApp in Google Apps Script to copy a Doc template, replace placeholders with replaceText(), build tables, and record links.

By the end

Generate one formatted fictional site report in a private folder while preserving its template and controlling reruns.

Chapter navigation

Make one report, not one document per row

Copy a Google Docs template into a target Drive folder, fill its placeholders from a structured report object, append a formatted measurements table, and save the generated document's ID in Script Properties so subsequent runs do not create duplicate reports.

Here DriveApp copies the template file into the destination folder, while DocumentApp reads the template paragraphs and builds the report body on the copy. The project expects a single document tab containing five template paragraphs and appends a four-row, three-column table (one header row plus three measurement rows).

Select a document, then its tab and body

A Google Doc organizes content in a hierarchy of tabs and structural elements:

Source
Document: one Drive file and document ID
  Tab: one tab ID, possibly with child tabs
    DocumentTab: the tab's document content
      Body
        Paragraph
          Text
        Table
          TableRow
            TableCell
              Paragraph
                Text

DocumentApp.openById(id) opens the document. document.getTabs() returns its top-level tabs, and tab.getChildTabs() returns any nested child tabs. Calling tab.asDocumentTab().getBody() selects that tab's body content, separate from any headers, footers, or footnotes. See the tabs guide and DocumentTab reference.

The project's getSingleReportTab_ helper verifies that the document has a single top-level tab with no child tabs before selecting tabs[0].asDocumentTab(). In a multi-tab document, select the target tab by ID with document.getTab(tabId) (which returns null if the tab ID is absent) rather than calling the legacy top-level document.getBody(), which targets only the active or first tab.

Within a tab's Body, getChild(index) returns a structural element. The template reader checks element.getType() before calling asParagraph(). A Paragraph controls block-level properties such as heading style and line spacing, while its Text child controls character-level font styling. Each TableCell contains its own Paragraph and Text children.

Create the private practice files

Create the template document, output folder, and standalone script project in My Drive:

  1. Create an empty folder named Site report practice outputs in My Drive, open it, and copy the folder ID after /folders/ in its URL (before any ? query string).

  2. Outside that folder, create a blank Google Doc named Site report template with a single document tab.

  3. Paste the five lines of template.txt shown below into the document body as plain text, with one line per paragraph (press Enter, not Shift+Enter, between lines). Keep the exact double-brace placeholders ({{Project}} appears in both the first and second paragraphs, and the fifth paragraph is Measurements).

    template.txt
    {{Project}} site report
    Prepared for {{Project}} by {{Reporter}}.
    Visit: {{VisitDate}}
    {{Summary}}
    Measurements
    

    Download template.txt
  4. Copy the template document ID between /document/d/ and /edit in its URL.

  5. At script.google.com, create a new standalone project named Site report practice. Replace Code.gs with the file below, then add a second script file named Data (Data.gs) containing the project's Data.gs source.

  6. In Project Settings, enable Show "appsscript.json" manifest file in editor, replace appsscript.json with the project manifest, and save all files.

  7. In Code.gs, replace templateId and destinationFolderId with your two Drive IDs, keeping outputName unchanged and confirmedOutputId set to ''.

Data.gs defines the report fields and measurement rows, including $25 and a literal backslash (\\ in JavaScript source, rendered as \ in the document) in summary so you can verify that special characters are inserted literally.

Code.gs
const SITE_REPORT = {
  templateId: 'REPLACE_WITH_TEMPLATE_DOCUMENT_ID',
  destinationFolderId: 'REPLACE_WITH_DESTINATION_FOLDER_ID',
  outputName: 'Harbor garden - site report',
  // Recovery only: the existing report you have inspected, never the template.
  confirmedOutputId: ''
};

/**
 * @typedef {{
 *   status: 'copy_pending'|'editing'|'complete',
 *   templateId: string,
 *   templateTabId: string,
 *   destinationFolderId: string,
 *   outputName: string,
 *   attemptedAt: string,
 *   paragraphs: string[],
 *   rows: string[][],
 *   outputId: string,
 *   outputTabId: string
 * }} SiteReportRecord
 */

const SITE_REPORT_RECORD_KEY = 'DOCS_SITE_REPORT';
const SITE_REPORT_MIME_TYPE = 'application/vnd.google-apps.document';

function inspectReportTemplate() {
  validateReportSettings_();
  const plan = prepareReport_();
  const destination = getReportDestination_();
  Logger.log('Template ID: ' + SITE_REPORT.templateId + '; tab ID: ' + plan.templateTabId);
  Logger.log('Destination: ' + destination.getName() + '; ID: ' + destination.getId());
  Logger.log('Planned paragraphs: ' + JSON.stringify(plan.paragraphs));
  Logger.log('Planned table: ' + JSON.stringify(plan.rows));
  showSiteReportRecord();
  Logger.log('Inspection only. No document contents or properties changed.');
}

function createSiteReport() {
  validateReportSettings_();
  withSiteReportLock_(() => {
    const previous = readSiteReportRecord_();
    if (previous !== null) {
      requireSameReportSettings_(previous);
      if (previous.status !== 'complete') {
        throw new Error('A report attempt is unresolved. Inspect DOCS_SITE_REPORT; do not create again.');
      }
      Logger.log('Already complete. No copy or content write. Output ID: ' + previous.outputId);
      Logger.log('Output URL: ' + DriveApp.getFileById(previous.outputId).getUrl());
      return;
    }

    const plan = prepareReport_();
    const destination = getReportDestination_();
    if (destination.getFilesByName(SITE_REPORT.outputName).hasNext()) {
      throw new Error('That output name already exists in the destination. Inspect it; no copy attempted.');
    }
    /** @type {SiteReportRecord} */
    const record = {
      status: 'copy_pending',
      templateId: SITE_REPORT.templateId,
      templateTabId: plan.templateTabId,
      destinationFolderId: SITE_REPORT.destinationFolderId,
      outputName: SITE_REPORT.outputName,
      attemptedAt: new Date().toISOString(),
      paragraphs: plan.paragraphs,
      rows: plan.rows,
      outputId: '',
      outputTabId: ''
    };
    // Preserve the plan and intent before the external copy. Never erase on error.
    saveSiteReportRecord_(record);
    const copy = plan.templateFile.makeCopy(record.outputName, destination);
    record.outputId = copy.getId();
    Logger.log('Drive returned output ID: ' + record.outputId);
    record.status = 'editing';
    // Save the output identity before any DocumentApp content mutation.
    saveSiteReportRecord_(record);
    renderRecordedReport_(record);
  });
}

function showSiteReportRecord() {
  const value = PropertiesService.getScriptProperties().getProperty(SITE_REPORT_RECORD_KEY);
  Logger.log(SITE_REPORT_RECORD_KEY + ': ' + (value === null ? 'No recorded attempt.' : value));
}

// Adopts an operator-confirmed existing copy. Does not create or edit a document.
function reconcileReportCopy() {
  validateReportSettings_();
  requireReportId_(SITE_REPORT.confirmedOutputId, 'confirmedOutputId');
  withSiteReportLock_(() => {
    const record = requireSiteReportRecord_();
    requireSameReportSettings_(record);
    if (record.status !== 'copy_pending') {
      if (record.outputId !== SITE_REPORT.confirmedOutputId) {
        throw new Error('A different output is already recorded. Do not replace its identity.');
      }
      Logger.log('That output is already recorded. No property or document changed.');
      return;
    }
    const file = requireReportOutput_(SITE_REPORT.confirmedOutputId, record);
    const document = DocumentApp.openById(file.getId());
    const tab = getSingleReportTab_(document);
    record.outputId = file.getId();
    record.outputTabId = tab.getId();
    record.status = 'editing';
    saveSiteReportRecord_(record);
    Logger.log('Existing copy recorded. Inspect it before choosing rebuildRecordedReport.');
  });
}

// DESTRUCTIVE TO THE RECORDED OUTPUT BODY: restores the saved plan, not annotations.
// The exact output ID must be typed into confirmedOutputId before this can run.
function rebuildRecordedReport() {
  validateReportSettings_();
  requireReportId_(SITE_REPORT.confirmedOutputId, 'confirmedOutputId');
  withSiteReportLock_(() => {
    const record = requireSiteReportRecord_();
    requireSameReportSettings_(record);
    if (record.status === 'copy_pending' || record.outputId !== SITE_REPORT.confirmedOutputId) {
      throw new Error('Confirm the recorded output ID; reconcile an unknown copy before rebuilding.');
    }
    renderRecordedReport_(record);
  });
}

function prepareReport_() {
  /** @type {{Project: string, Reporter: string, VisitDate: string, Summary: string}} */
  const replacements = {
    Project: SITE_REPORT_DATA.project,
    Reporter: SITE_REPORT_DATA.reporter,
    VisitDate: SITE_REPORT_DATA.visitDate,
    Summary: SITE_REPORT_DATA.summary
  };
  /** @type {Array<keyof typeof replacements>} */
  const replacementKeys = ['Project', 'Reporter', 'VisitDate', 'Summary'];
  for (const key of replacementKeys) {
    requireReportText_(replacements[key], key, 120);
  }
  const measurements = SITE_REPORT_DATA.measurements;
  if (!Array.isArray(measurements) || measurements.length < 1 || measurements.length > 3) {
    throw new Error('Supply 1-3 fictional measurements in Data.gs.');
  }
  const rows = [['Area', 'Planters', 'Note']];
  for (const item of measurements) {
    if (!item || typeof item !== 'object') throw new Error('Each measurement must be an object.');
    requireReportText_(item.area, 'area', 60);
    requireReportText_(item.note, 'note', 60);
    if (!Number.isInteger(item.planters) || item.planters < 0 || item.planters > 20) {
      throw new Error('planters must be an integer from 0 to 20.');
    }
    rows.push([item.area, String(item.planters), item.note]);
  }

  const templateFile = DriveApp.getFileById(SITE_REPORT.templateId);
  if (templateFile.isTrashed() || templateFile.getMimeType() !== SITE_REPORT_MIME_TYPE) {
    throw new Error('The template must be an untrashed Google Doc, not a shortcut.');
  }
  const document = DocumentApp.openById(SITE_REPORT.templateId);
  const tab = getSingleReportTab_(document);
  const body = tab.asDocumentTab().getBody();
  const count = body.getNumChildren();
  if (count !== 5 && count !== 6) {
    throw new Error('Use the five template paragraphs, with at most one empty trailing paragraph.');
  }
  const templateLines = [];
  for (let index = 0; index < count; index += 1) {
    const element = body.getChild(index);
    if (element.getType() !== DocumentApp.ElementType.PARAGRAPH) {
      throw new Error('The template body must contain only the supplied paragraphs, not lists or tables.');
    }
    const paragraph = element.asParagraph();
    if (paragraph.getNumChildren() > 12 || paragraph.getPositionedImages().length !== 0) {
      throw new Error('Use short, text-only template paragraphs without images.');
    }
    for (let child = 0; child < paragraph.getNumChildren(); child += 1) {
      if (paragraph.getChild(child).getType() !== DocumentApp.ElementType.TEXT) {
        throw new Error('Remove non-text elements from the practice template.');
      }
    }
    const text = paragraph.getText();
    if (index === 5 && text === '') continue;
    requireReportText_(text, 'template paragraph ' + (index + 1), 200);
    templateLines.push(text);
  }
  if (templateLines.length !== 5) throw new Error('The sixth template paragraph must be empty.');
  const requiredKeys = [['Project'], ['Project', 'Reporter'], ['VisitDate'], ['Summary'], []];
  const paragraphs = templateLines.map((line, index) => {
    /** @type {string[]} */
    const seen = [];
    const pattern = /\{\{([A-Za-z]+)\}\}/g;
    const withoutTokens = line.replace(pattern, '');
    if (withoutTokens.includes('{{') || withoutTokens.includes('}}')) {
      throw new Error('Malformed placeholder in template paragraph ' + (index + 1) + '.');
    }
    const filled = line.replace(pattern, (/** @type {string} */ match, /** @type {string} */ key) => {
      if (!requiredKeys[index].includes(key)) {
        throw new Error('Unexpected placeholder ' + match + ' in paragraph ' + (index + 1) + '.');
      }
      seen.push(key);
      // Callback results are literal, even when the value contains $& or backslashes.
      return reportReplacement_(key, replacements);
    });
    if (requiredKeys[index].some((key) => !seen.includes(key))) {
      throw new Error('A required placeholder is missing from paragraph ' + (index + 1) + '.');
    }
    requireReportText_(filled, 'filled paragraph', 500);
    return filled;
  });
  return { templateFile: templateFile, templateTabId: tab.getId(), paragraphs: paragraphs, rows: rows };
}

/**
 * @param {string} key
 * @param {{Project: string, Reporter: string, VisitDate: string, Summary: string}} replacements
 * @returns {string}
 */
function reportReplacement_(key, replacements) {
  switch (key) {
    case 'Project': return replacements.Project;
    case 'Reporter': return replacements.Reporter;
    case 'VisitDate': return replacements.VisitDate;
    case 'Summary': return replacements.Summary;
    default: throw new Error('Unexpected replacement key.');
  }
}

/**
 * @param {SiteReportRecord} record
 * @returns {void}
 */
function renderRecordedReport_(record) {
  const file = requireReportOutput_(record.outputId, record);
  const document = DocumentApp.openById(record.outputId);
  const tab = getSingleReportTab_(document);
  if (record.outputTabId !== '' && record.outputTabId !== tab.getId()) {
    throw new Error('The recorded output tab was replaced. Stop rather than edit another tab.');
  }
  record.outputTabId = tab.getId();
  record.status = 'editing';
  saveSiteReportRecord_(record);

  const body = tab.asDocumentTab().getBody();
  // This report owns the entire body. Reset it so a rebuild cannot append duplicates.
  body.setText(record.paragraphs[0]);
  for (let index = 0; index < record.paragraphs.length; index += 1) {
    const paragraph = index === 0
      ? body.getChild(0).asParagraph()
      : body.appendParagraph(record.paragraphs[index]);
    const heading = index === 0 ? DocumentApp.ParagraphHeading.TITLE
      : index === 4 ? DocumentApp.ParagraphHeading.HEADING2
      : DocumentApp.ParagraphHeading.NORMAL;
    paragraph.setHeading(heading).setSpacingAfter(8);
    paragraph.editAsText().setFontFamily('Arial')
      .setFontSize(index === 0 ? 20 : index === 4 ? 14 : 11)
      .setBold(index === 0 || index === 4)
      .setForegroundColor(index === 0 ? '#1F4E3D' : '#202124');
  }
  const table = body.appendTable(record.rows);
  table.setBorderWidth(1);
  for (let row = 0; row < record.rows.length; row += 1) {
    for (let column = 0; column < 3; column += 1) {
      const cell = table.getCell(row, column);
      cell.getChild(0).asParagraph().setHeading(DocumentApp.ParagraphHeading.NORMAL);
      cell.setBackgroundColor(row === 0 ? '#E8F0E9' : '#FFFFFF');
      cell.editAsText().setFontFamily('Arial').setFontSize(10)
        .setBold(row === 0).setForegroundColor('#202124');
    }
  }
  // Applies pending Docs changes; it is not a transaction with the property store.
  document.saveAndClose();
  record.status = 'complete';
  saveSiteReportRecord_(record);
  Logger.log('Report saved. Output ID: ' + record.outputId + '; tab ID: ' + record.outputTabId);
  Logger.log('Output URL: ' + file.getUrl());
}

/**
 * @param {DocumentApp.Document} document
 * @returns {DocumentApp.Tab}
 */
function getSingleReportTab_(document) {
  const tabs = document.getTabs();
  if (tabs.length !== 1 || tabs[0].getChildTabs().length !== 0 ||
      tabs[0].getType() !== DocumentApp.TabType.DOCUMENT_TAB) {
    throw new Error('Use exactly one document tab with no child tabs.');
  }
  const documentTab = tabs[0].asDocumentTab();
  const footnotes = documentTab.getFootnotes();
  if (documentTab.getHeader() !== null || documentTab.getFooter() !== null ||
      (footnotes !== null && footnotes.length !== 0)) {
    throw new Error('Use a practice document without headers, footers, or footnotes.');
  }
  return tabs[0];
}

/**
 * @param {string} id
 * @param {SiteReportRecord} record
 * @returns {DriveApp.File}
 */
function requireReportOutput_(id, record) {
  requireReportId_(id, 'outputId');
  if (id === record.templateId) throw new Error('The output cannot be the original template.');
  const destination = getReportDestination_();
  const file = DriveApp.getFileById(id);
  if (file.isTrashed() || file.getMimeType() !== SITE_REPORT_MIME_TYPE || file.getName() !== record.outputName) {
    throw new Error('Inspect the output: it must be an untrashed Google Doc with the recorded name.');
  }
  const parents = file.getParents();
  if (!parents.hasNext() || parents.next().getId() !== destination.getId() || parents.hasNext()) {
    throw new Error('The output must be directly in the recorded destination only.');
  }
  return file;
}

function getReportDestination_() {
  const folder = DriveApp.getFolderById(SITE_REPORT.destinationFolderId);
  if (folder.isTrashed()) throw new Error('Restore the intended destination folder from Trash.');
  return folder;
}

function validateReportSettings_() {
  requireReportId_(SITE_REPORT.templateId, 'templateId');
  requireReportId_(SITE_REPORT.destinationFolderId, 'destinationFolderId');
  if (SITE_REPORT.templateId === SITE_REPORT.destinationFolderId) {
    throw new Error('Use a document ID and a different folder ID.');
  }
  requireReportText_(SITE_REPORT.outputName, 'outputName', 80);
}

/**
 * @param {string} value
 * @param {string} label
 * @returns {void}
 */
function requireReportId_(value, label) {
  if (typeof value !== 'string' || value.startsWith('REPLACE_') ||
      value.length > 200 || !/^[A-Za-z0-9_-]+$/.test(value)) {
    throw new Error('Set ' + label + ' to the selected item ID, not a name or full URL.');
  }
}

/**
 * @param {string} value
 * @param {string} label
 * @param {number} limit
 * @returns {void}
 */
function requireReportText_(value, label, limit) {
  if (typeof value !== 'string' || value.trim().length === 0 || value.length > limit || /[\r\n\t]/.test(value)) {
    throw new Error(label + ' must be nonempty, single-line text of at most ' + limit + ' characters.');
  }
}

/**
 * @param {function(): void} action
 * @returns {void}
 */
function withSiteReportLock_(action) {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) throw new Error('Another report operation is running. Wait, then inspect the record.');
  try {
    action();
  } finally {
    lock.releaseLock();
  }
}

/**
 * @param {SiteReportRecord} record
 * @returns {void}
 */
function saveSiteReportRecord_(record) {
  PropertiesService.getScriptProperties().setProperty(SITE_REPORT_RECORD_KEY, JSON.stringify(record));
}

function readSiteReportRecord_() {
  const value = PropertiesService.getScriptProperties().getProperty(SITE_REPORT_RECORD_KEY);
  if (value === null) return null;
  /** @type {SiteReportRecord} */
  let record;
  try {
    record = JSON.parse(value);
  } catch (error) {
    throw new Error('The report record is unreadable. Preserve it; do not start over by deleting it.');
  }
  if (!record || typeof record !== 'object' || Array.isArray(record) ||
      !['copy_pending', 'editing', 'complete'].includes(record.status) ||
      typeof record.templateTabId !== 'string' || typeof record.outputTabId !== 'string' ||
      typeof record.attemptedAt !== 'string' || typeof record.outputId !== 'string') {
    throw new Error('Unexpected report record. Preserve it and investigate.');
  }
  requireReportId_(record.templateId, 'record.templateId');
  requireReportId_(record.destinationFolderId, 'record.destinationFolderId');
  requireReportText_(record.outputName, 'record.outputName', 80);
  if (!Array.isArray(record.paragraphs) || record.paragraphs.length !== 5 ||
      !Array.isArray(record.rows) || record.rows.length < 2 || record.rows.length > 4) {
    throw new Error('The saved report plan has an unexpected size. Preserve it.');
  }
  record.paragraphs.forEach((text) => requireReportText_(text, 'saved paragraph', 500));
  for (const row of record.rows) {
    if (!Array.isArray(row) || row.length !== 3) throw new Error('A saved table row must have three cells.');
    row.forEach((text) => requireReportText_(text, 'saved table cell', 60));
  }
  if (record.status === 'copy_pending') {
    if (record.outputId !== '' || record.outputTabId !== '') throw new Error('Unexpected pending-copy identity.');
  } else {
    requireReportId_(record.outputId, 'record.outputId');
    if (record.outputId === record.templateId) throw new Error('The record identifies the template as the output.');
    if (record.status === 'complete' && record.outputTabId === '') throw new Error('The completed record lacks its tab ID.');
  }
  return record;
}

function requireSiteReportRecord_() {
  const record = readSiteReportRecord_();
  if (record === null) throw new Error('No report attempt is recorded. Recovery cannot invent an attempt.');
  return record;
}

/**
 * @param {SiteReportRecord} record
 * @returns {void}
 */
function requireSameReportSettings_(record) {
  if (record.templateId !== SITE_REPORT.templateId ||
      record.destinationFolderId !== SITE_REPORT.destinationFolderId ||
      record.outputName !== SITE_REPORT.outputName) {
    throw new Error('Settings differ from the recorded attempt. Restore them before proceeding.');
  }
}

Download Code.gs
Data.gs
// Fictional data for one short report. No spreadsheet or external API is used.
const SITE_REPORT_DATA = {
  project: 'Harbor garden',
  reporter: 'Rowan',
  visitDate: '2026-09-11',
  summary: 'Twelve planters checked. Keep $25 for labels; file the sketch in Plans\\West.',
  measurements: [
    { area: 'North bed', planters: 6, note: 'Ready' },
    { area: 'Path edge', planters: 2, note: 'Needs soil' },
    { area: 'Courtyard', planters: 4, note: 'Water first' }
  ]
};

Download Data.gs
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/documents",
    "https://www.googleapis.com/auth/drive"
  ]
}

Download appsscript.json

Before accessing Drive or Docs, the script checks that templateId and destinationFolderId are non-empty ID strings rather than placeholders or full URLs.

Authorize, inspect, and change the plan

The manifest requests https://www.googleapis.com/auth/documents to open and edit Google Docs by ID and https://www.googleapis.com/auth/drive to copy the template and inspect the destination folder (see the authorization guide and File.makeCopy).

  1. Select inspectReportTemplate and click Run, authorizing the project when prompted.

  2. In Execution log, confirm the template and destination IDs, the five rendered paragraph strings, the header plus three table rows, and No recorded attempt.

  3. Verify that Site report practice outputs is still empty and Site report template still has its {{...}} placeholders; inspectReportTemplate only reads the template and logs the planned report.

To verify how literal replacement handles special regex characters, temporarily set project in Data.gs to 'Harbor garden (west)' and summary to 'Literal $& and .*; keep $25 for labels.', then run inspectReportTemplate. Both {{Project}} placeholders show Harbor garden (west), and the summary retains $& and .* as literal characters. Restore both values in Data.gs and run inspectReportTemplate again.

To test template validation before copying, temporarily delete {{Reporter}} from the second paragraph of Site report template and run inspectReportTemplate. Validation throws an error for the missing placeholder without creating a copy. Restore the second paragraph from template.txt and re-run inspectReportTemplate to confirm the template passes before proceeding.

Fill literal values without losing track of structure

The template reader reads each of the five paragraphs with getText() and replaces placeholders in memory using /\{\{([A-Za-z]+)\}\}/g with String.prototype.replace:

  • \{ and \} match literal braces.

  • The capturing group ([A-Za-z]+) extracts the placeholder name for the replacement callback.

  • The g flag replaces every match in the paragraph.

Each paragraph enforces its own required placeholder set and rejects unknown placeholder names or unmatched braces. Because String.prototype.replace invokes a callback function that returns the replacement string directly, replacement patterns such as $& are inserted literally rather than interpreted as regex backreferences, and the returned text is not rescanned for nested placeholders.

By contrast, Body.replaceText and Text.replaceText take an RE2 regular-expression string (where literal braces and dots must be escaped, such as '\\{\\{Project\\}\\}') and match within individual text runs. Because this project rebuilds the five plain paragraphs and applies block and character formatting afterward, it does not depend on how Docs splits runs inside the template.

For the measurements table, the script maps the measurement objects to a 2D array of strings (converting item.planters with String(item.planters)) and passes that array to Body.appendTable(rows).

Create and inspect the report

createSiteReport validates the template and saves the prepared paragraph and table snapshot to Script Properties before calling makeCopy(outputName, destination). Once Drive returns the new file, the script saves the copy's outputId before opening the copy with DocumentApp.openById(outputId) to write its body.

  1. Select createSiteReport and click Run once.

  2. Open the output URL from Execution log and confirm that it is a new document inside Site report practice outputs.

  3. Verify the five paragraphs: title Harbor garden site report, subtitle Prepared for Harbor garden by Rowan., date Visit: 2026-09-11, summary containing $25 and Plans\West, and heading Measurements.

  4. Verify the table under Measurements has headers Area, Planters, and Note followed by North bed / 6 / Ready, Path edge / 2 / Needs soil, and Courtyard / 4 / Water first.

  5. Confirm that Site report template still has its original {{...}} placeholders.

  6. Run showSiteReportRecord and confirm status: 'complete', outputId, and outputTabId.

To format the generated document, the renderer clears the body with body.setText(...), appends the remaining paragraphs, sets paragraph headings (TITLE and HEADING2) and spacing, and styles text runs through paragraph.editAsText() (20 pt dark green title, 14 pt section heading, 11 pt body text; see Body, Paragraph, and Text). It then styles the zero-indexed table cells via table.getCell(row, column) and TableCell, applying a pale green background and bold text to header row 0 and a 1 pt border to the table. Note that appendTable leaves a trailing empty paragraph because a Google Doc body cannot end with a table element.

Understand the saved record before repeating

The project stores its checkpoint JSON string under the key DOCS_SITE_REPORT in PropertiesService.getScriptProperties() (see the Properties service guide), including the rendered paragraph and table snapshot:

Recorded statusWhat it establishesWhat it does not establish
No recordThis project has no saved attempt.Whether a same-named file already exists in the destination folder (the initial name check stops if one is present).
copy_pendingThe rendered snapshot and intent were saved before calling makeCopy().Whether Drive created the copy before the execution stopped.
editingThe copy's outputId and outputTabId are saved; body formatting is in progress or unconfirmed.Whether the copy's body is untouched, partially written, or complete.
completesaveAndClose() finished and the completion record was saved.Whether the output document was later edited, moved, or trashed in Drive.

withSiteReportLock_ acquires LockService.getScriptLock() with tryLock(1000) and releases it in finally (see the Lock reference) so two concurrent runs in this project cannot both see an empty record and create two copies.

To verify repeat behavior, change project in Data.gs to 'Harbor garden revised', save, and run createSiteReport again. Because DOCS_SITE_REPORT is already complete, the function logs the existing outputId without copying the template or rewriting the document. Restore Data.gs afterward.

Recover on the known output, not a new copy

Copying in Drive, editing in Docs, and updating Script Properties are three separate operations. document.saveAndClose() flushes pending Docs changes, and Apps Script also flushes open documents when an execution terminates, so an error partway through formatting can leave partial edits in the copy.

If a run fails, wait for active executions to finish in Executions and run showSiteReportRecord:

  • If status is copy_pending, check Site report practice outputs in Drive and the execution log (which prints Drive returned output ID immediately after makeCopy()) to see whether the copy was created. Once you identify the copy, paste its document ID into confirmedOutputId in Code.gs, save, and run reconcileReportCopy. That function verifies the file's ID, type, trash status, name, parent folder, and single tab, then advances DOCS_SITE_REPORT to editing without modifying the document body.

  • Once status is editing (either directly from an interrupted edit or after reconcileReportCopy), set confirmedOutputId to the recorded outputId.

Run rebuildRecordedReport to rewrite and format the confirmed document from the saved snapshot without creating another Drive copy, then reset confirmedOutputId to '' and save Code.gs.

To test rebuildRecordedReport directly, append the line Practice annotation to remove to the bottom of your generated report in Google Docs, set confirmedOutputId in Code.gs to the report's document ID, save, and run rebuildRecordedReport. The added line is removed, the saved five paragraphs and table are restored on the same document ID, and DOCS_SITE_REPORT returns to complete. Reset confirmedOutputId to '' and save.

Interpret failures and clean up

ObservationAction
Invalid ID, missing placeholder, wrong paragraph count, or extra tabFix the configuration in Code.gs or restore Site report template from template.txt, then re-run inspectReportTemplate.
Authorization, copying, or destination access deniedVerify the executing account, granted scopes, and Drive permissions on the template and destination folder.
Another report operation is runningWait for the active execution to finish in Executions, then run showSiteReportRecord.
Same-named output with no recordInspect the existing file in Site report practice outputs before deciding whether to move or trash it.
Settings differ from the recorded attemptRestore templateId, destinationFolderId, and outputName to match showSiteReportRecord.
Runtime, service, or quota failure after an attempt startsRun showSiteReportRecord and follow the copy_pending or editing recovery path above.

Because this project stores a single report checkpoint under DOCS_SITE_REPORT, when you finish the exercise you can move the generated report, Site report template, Site report practice outputs, and the Site report practice script project to Trash, and optionally revoke the project's OAuth grant in your Google Account settings.

Know when to use the advanced APIs

DocumentApp and DriveApp cover single-tab template rendering, paragraph and table styling, and My Drive file copying.

Enable the advanced Docs service (Docs) when you need atomic document-level documents.batchUpdate requests, replacement match counts, or optimistic concurrency via writeControl.requiredRevisionId. When using the Docs REST API with multi-tab documents, pass includeTabsContent: true on reads and specify target tab IDs on writes, noting that some REST operations default to the first tab while others (replaceAllText) can span all tabs when no tab filter is supplied (see the Docs API tabs guide). Enable the advanced Drive service (Drive) when you need shared-drive support, custom file properties, or Drive revision metadata.

Next, search Gmail threads, create drafts, and reconcile delivery state.

Project files

Complete source files for this chapter’s examples.

Generate one site report from a Docs template
Code.gs
const SITE_REPORT = {
  templateId: 'REPLACE_WITH_TEMPLATE_DOCUMENT_ID',
  destinationFolderId: 'REPLACE_WITH_DESTINATION_FOLDER_ID',
  outputName: 'Harbor garden - site report',
  // Recovery only: the existing report you have inspected, never the template.
  confirmedOutputId: ''
};

/**
 * @typedef {{
 *   status: 'copy_pending'|'editing'|'complete',
 *   templateId: string,
 *   templateTabId: string,
 *   destinationFolderId: string,
 *   outputName: string,
 *   attemptedAt: string,
 *   paragraphs: string[],
 *   rows: string[][],
 *   outputId: string,
 *   outputTabId: string
 * }} SiteReportRecord
 */

const SITE_REPORT_RECORD_KEY = 'DOCS_SITE_REPORT';
const SITE_REPORT_MIME_TYPE = 'application/vnd.google-apps.document';

function inspectReportTemplate() {
  validateReportSettings_();
  const plan = prepareReport_();
  const destination = getReportDestination_();
  Logger.log('Template ID: ' + SITE_REPORT.templateId + '; tab ID: ' + plan.templateTabId);
  Logger.log('Destination: ' + destination.getName() + '; ID: ' + destination.getId());
  Logger.log('Planned paragraphs: ' + JSON.stringify(plan.paragraphs));
  Logger.log('Planned table: ' + JSON.stringify(plan.rows));
  showSiteReportRecord();
  Logger.log('Inspection only. No document contents or properties changed.');
}

function createSiteReport() {
  validateReportSettings_();
  withSiteReportLock_(() => {
    const previous = readSiteReportRecord_();
    if (previous !== null) {
      requireSameReportSettings_(previous);
      if (previous.status !== 'complete') {
        throw new Error('A report attempt is unresolved. Inspect DOCS_SITE_REPORT; do not create again.');
      }
      Logger.log('Already complete. No copy or content write. Output ID: ' + previous.outputId);
      Logger.log('Output URL: ' + DriveApp.getFileById(previous.outputId).getUrl());
      return;
    }

    const plan = prepareReport_();
    const destination = getReportDestination_();
    if (destination.getFilesByName(SITE_REPORT.outputName).hasNext()) {
      throw new Error('That output name already exists in the destination. Inspect it; no copy attempted.');
    }
    /** @type {SiteReportRecord} */
    const record = {
      status: 'copy_pending',
      templateId: SITE_REPORT.templateId,
      templateTabId: plan.templateTabId,
      destinationFolderId: SITE_REPORT.destinationFolderId,
      outputName: SITE_REPORT.outputName,
      attemptedAt: new Date().toISOString(),
      paragraphs: plan.paragraphs,
      rows: plan.rows,
      outputId: '',
      outputTabId: ''
    };
    // Preserve the plan and intent before the external copy. Never erase on error.
    saveSiteReportRecord_(record);
    const copy = plan.templateFile.makeCopy(record.outputName, destination);
    record.outputId = copy.getId();
    Logger.log('Drive returned output ID: ' + record.outputId);
    record.status = 'editing';
    // Save the output identity before any DocumentApp content mutation.
    saveSiteReportRecord_(record);
    renderRecordedReport_(record);
  });
}

function showSiteReportRecord() {
  const value = PropertiesService.getScriptProperties().getProperty(SITE_REPORT_RECORD_KEY);
  Logger.log(SITE_REPORT_RECORD_KEY + ': ' + (value === null ? 'No recorded attempt.' : value));
}

// Adopts an operator-confirmed existing copy. Does not create or edit a document.
function reconcileReportCopy() {
  validateReportSettings_();
  requireReportId_(SITE_REPORT.confirmedOutputId, 'confirmedOutputId');
  withSiteReportLock_(() => {
    const record = requireSiteReportRecord_();
    requireSameReportSettings_(record);
    if (record.status !== 'copy_pending') {
      if (record.outputId !== SITE_REPORT.confirmedOutputId) {
        throw new Error('A different output is already recorded. Do not replace its identity.');
      }
      Logger.log('That output is already recorded. No property or document changed.');
      return;
    }
    const file = requireReportOutput_(SITE_REPORT.confirmedOutputId, record);
    const document = DocumentApp.openById(file.getId());
    const tab = getSingleReportTab_(document);
    record.outputId = file.getId();
    record.outputTabId = tab.getId();
    record.status = 'editing';
    saveSiteReportRecord_(record);
    Logger.log('Existing copy recorded. Inspect it before choosing rebuildRecordedReport.');
  });
}

// DESTRUCTIVE TO THE RECORDED OUTPUT BODY: restores the saved plan, not annotations.
// The exact output ID must be typed into confirmedOutputId before this can run.
function rebuildRecordedReport() {
  validateReportSettings_();
  requireReportId_(SITE_REPORT.confirmedOutputId, 'confirmedOutputId');
  withSiteReportLock_(() => {
    const record = requireSiteReportRecord_();
    requireSameReportSettings_(record);
    if (record.status === 'copy_pending' || record.outputId !== SITE_REPORT.confirmedOutputId) {
      throw new Error('Confirm the recorded output ID; reconcile an unknown copy before rebuilding.');
    }
    renderRecordedReport_(record);
  });
}

function prepareReport_() {
  /** @type {{Project: string, Reporter: string, VisitDate: string, Summary: string}} */
  const replacements = {
    Project: SITE_REPORT_DATA.project,
    Reporter: SITE_REPORT_DATA.reporter,
    VisitDate: SITE_REPORT_DATA.visitDate,
    Summary: SITE_REPORT_DATA.summary
  };
  /** @type {Array<keyof typeof replacements>} */
  const replacementKeys = ['Project', 'Reporter', 'VisitDate', 'Summary'];
  for (const key of replacementKeys) {
    requireReportText_(replacements[key], key, 120);
  }
  const measurements = SITE_REPORT_DATA.measurements;
  if (!Array.isArray(measurements) || measurements.length < 1 || measurements.length > 3) {
    throw new Error('Supply 1-3 fictional measurements in Data.gs.');
  }
  const rows = [['Area', 'Planters', 'Note']];
  for (const item of measurements) {
    if (!item || typeof item !== 'object') throw new Error('Each measurement must be an object.');
    requireReportText_(item.area, 'area', 60);
    requireReportText_(item.note, 'note', 60);
    if (!Number.isInteger(item.planters) || item.planters < 0 || item.planters > 20) {
      throw new Error('planters must be an integer from 0 to 20.');
    }
    rows.push([item.area, String(item.planters), item.note]);
  }

  const templateFile = DriveApp.getFileById(SITE_REPORT.templateId);
  if (templateFile.isTrashed() || templateFile.getMimeType() !== SITE_REPORT_MIME_TYPE) {
    throw new Error('The template must be an untrashed Google Doc, not a shortcut.');
  }
  const document = DocumentApp.openById(SITE_REPORT.templateId);
  const tab = getSingleReportTab_(document);
  const body = tab.asDocumentTab().getBody();
  const count = body.getNumChildren();
  if (count !== 5 && count !== 6) {
    throw new Error('Use the five template paragraphs, with at most one empty trailing paragraph.');
  }
  const templateLines = [];
  for (let index = 0; index < count; index += 1) {
    const element = body.getChild(index);
    if (element.getType() !== DocumentApp.ElementType.PARAGRAPH) {
      throw new Error('The template body must contain only the supplied paragraphs, not lists or tables.');
    }
    const paragraph = element.asParagraph();
    if (paragraph.getNumChildren() > 12 || paragraph.getPositionedImages().length !== 0) {
      throw new Error('Use short, text-only template paragraphs without images.');
    }
    for (let child = 0; child < paragraph.getNumChildren(); child += 1) {
      if (paragraph.getChild(child).getType() !== DocumentApp.ElementType.TEXT) {
        throw new Error('Remove non-text elements from the practice template.');
      }
    }
    const text = paragraph.getText();
    if (index === 5 && text === '') continue;
    requireReportText_(text, 'template paragraph ' + (index + 1), 200);
    templateLines.push(text);
  }
  if (templateLines.length !== 5) throw new Error('The sixth template paragraph must be empty.');
  const requiredKeys = [['Project'], ['Project', 'Reporter'], ['VisitDate'], ['Summary'], []];
  const paragraphs = templateLines.map((line, index) => {
    /** @type {string[]} */
    const seen = [];
    const pattern = /\{\{([A-Za-z]+)\}\}/g;
    const withoutTokens = line.replace(pattern, '');
    if (withoutTokens.includes('{{') || withoutTokens.includes('}}')) {
      throw new Error('Malformed placeholder in template paragraph ' + (index + 1) + '.');
    }
    const filled = line.replace(pattern, (/** @type {string} */ match, /** @type {string} */ key) => {
      if (!requiredKeys[index].includes(key)) {
        throw new Error('Unexpected placeholder ' + match + ' in paragraph ' + (index + 1) + '.');
      }
      seen.push(key);
      // Callback results are literal, even when the value contains $& or backslashes.
      return reportReplacement_(key, replacements);
    });
    if (requiredKeys[index].some((key) => !seen.includes(key))) {
      throw new Error('A required placeholder is missing from paragraph ' + (index + 1) + '.');
    }
    requireReportText_(filled, 'filled paragraph', 500);
    return filled;
  });
  return { templateFile: templateFile, templateTabId: tab.getId(), paragraphs: paragraphs, rows: rows };
}

/**
 * @param {string} key
 * @param {{Project: string, Reporter: string, VisitDate: string, Summary: string}} replacements
 * @returns {string}
 */
function reportReplacement_(key, replacements) {
  switch (key) {
    case 'Project': return replacements.Project;
    case 'Reporter': return replacements.Reporter;
    case 'VisitDate': return replacements.VisitDate;
    case 'Summary': return replacements.Summary;
    default: throw new Error('Unexpected replacement key.');
  }
}

/**
 * @param {SiteReportRecord} record
 * @returns {void}
 */
function renderRecordedReport_(record) {
  const file = requireReportOutput_(record.outputId, record);
  const document = DocumentApp.openById(record.outputId);
  const tab = getSingleReportTab_(document);
  if (record.outputTabId !== '' && record.outputTabId !== tab.getId()) {
    throw new Error('The recorded output tab was replaced. Stop rather than edit another tab.');
  }
  record.outputTabId = tab.getId();
  record.status = 'editing';
  saveSiteReportRecord_(record);

  const body = tab.asDocumentTab().getBody();
  // This report owns the entire body. Reset it so a rebuild cannot append duplicates.
  body.setText(record.paragraphs[0]);
  for (let index = 0; index < record.paragraphs.length; index += 1) {
    const paragraph = index === 0
      ? body.getChild(0).asParagraph()
      : body.appendParagraph(record.paragraphs[index]);
    const heading = index === 0 ? DocumentApp.ParagraphHeading.TITLE
      : index === 4 ? DocumentApp.ParagraphHeading.HEADING2
      : DocumentApp.ParagraphHeading.NORMAL;
    paragraph.setHeading(heading).setSpacingAfter(8);
    paragraph.editAsText().setFontFamily('Arial')
      .setFontSize(index === 0 ? 20 : index === 4 ? 14 : 11)
      .setBold(index === 0 || index === 4)
      .setForegroundColor(index === 0 ? '#1F4E3D' : '#202124');
  }
  const table = body.appendTable(record.rows);
  table.setBorderWidth(1);
  for (let row = 0; row < record.rows.length; row += 1) {
    for (let column = 0; column < 3; column += 1) {
      const cell = table.getCell(row, column);
      cell.getChild(0).asParagraph().setHeading(DocumentApp.ParagraphHeading.NORMAL);
      cell.setBackgroundColor(row === 0 ? '#E8F0E9' : '#FFFFFF');
      cell.editAsText().setFontFamily('Arial').setFontSize(10)
        .setBold(row === 0).setForegroundColor('#202124');
    }
  }
  // Applies pending Docs changes; it is not a transaction with the property store.
  document.saveAndClose();
  record.status = 'complete';
  saveSiteReportRecord_(record);
  Logger.log('Report saved. Output ID: ' + record.outputId + '; tab ID: ' + record.outputTabId);
  Logger.log('Output URL: ' + file.getUrl());
}

/**
 * @param {DocumentApp.Document} document
 * @returns {DocumentApp.Tab}
 */
function getSingleReportTab_(document) {
  const tabs = document.getTabs();
  if (tabs.length !== 1 || tabs[0].getChildTabs().length !== 0 ||
      tabs[0].getType() !== DocumentApp.TabType.DOCUMENT_TAB) {
    throw new Error('Use exactly one document tab with no child tabs.');
  }
  const documentTab = tabs[0].asDocumentTab();
  const footnotes = documentTab.getFootnotes();
  if (documentTab.getHeader() !== null || documentTab.getFooter() !== null ||
      (footnotes !== null && footnotes.length !== 0)) {
    throw new Error('Use a practice document without headers, footers, or footnotes.');
  }
  return tabs[0];
}

/**
 * @param {string} id
 * @param {SiteReportRecord} record
 * @returns {DriveApp.File}
 */
function requireReportOutput_(id, record) {
  requireReportId_(id, 'outputId');
  if (id === record.templateId) throw new Error('The output cannot be the original template.');
  const destination = getReportDestination_();
  const file = DriveApp.getFileById(id);
  if (file.isTrashed() || file.getMimeType() !== SITE_REPORT_MIME_TYPE || file.getName() !== record.outputName) {
    throw new Error('Inspect the output: it must be an untrashed Google Doc with the recorded name.');
  }
  const parents = file.getParents();
  if (!parents.hasNext() || parents.next().getId() !== destination.getId() || parents.hasNext()) {
    throw new Error('The output must be directly in the recorded destination only.');
  }
  return file;
}

function getReportDestination_() {
  const folder = DriveApp.getFolderById(SITE_REPORT.destinationFolderId);
  if (folder.isTrashed()) throw new Error('Restore the intended destination folder from Trash.');
  return folder;
}

function validateReportSettings_() {
  requireReportId_(SITE_REPORT.templateId, 'templateId');
  requireReportId_(SITE_REPORT.destinationFolderId, 'destinationFolderId');
  if (SITE_REPORT.templateId === SITE_REPORT.destinationFolderId) {
    throw new Error('Use a document ID and a different folder ID.');
  }
  requireReportText_(SITE_REPORT.outputName, 'outputName', 80);
}

/**
 * @param {string} value
 * @param {string} label
 * @returns {void}
 */
function requireReportId_(value, label) {
  if (typeof value !== 'string' || value.startsWith('REPLACE_') ||
      value.length > 200 || !/^[A-Za-z0-9_-]+$/.test(value)) {
    throw new Error('Set ' + label + ' to the selected item ID, not a name or full URL.');
  }
}

/**
 * @param {string} value
 * @param {string} label
 * @param {number} limit
 * @returns {void}
 */
function requireReportText_(value, label, limit) {
  if (typeof value !== 'string' || value.trim().length === 0 || value.length > limit || /[\r\n\t]/.test(value)) {
    throw new Error(label + ' must be nonempty, single-line text of at most ' + limit + ' characters.');
  }
}

/**
 * @param {function(): void} action
 * @returns {void}
 */
function withSiteReportLock_(action) {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(1000)) throw new Error('Another report operation is running. Wait, then inspect the record.');
  try {
    action();
  } finally {
    lock.releaseLock();
  }
}

/**
 * @param {SiteReportRecord} record
 * @returns {void}
 */
function saveSiteReportRecord_(record) {
  PropertiesService.getScriptProperties().setProperty(SITE_REPORT_RECORD_KEY, JSON.stringify(record));
}

function readSiteReportRecord_() {
  const value = PropertiesService.getScriptProperties().getProperty(SITE_REPORT_RECORD_KEY);
  if (value === null) return null;
  /** @type {SiteReportRecord} */
  let record;
  try {
    record = JSON.parse(value);
  } catch (error) {
    throw new Error('The report record is unreadable. Preserve it; do not start over by deleting it.');
  }
  if (!record || typeof record !== 'object' || Array.isArray(record) ||
      !['copy_pending', 'editing', 'complete'].includes(record.status) ||
      typeof record.templateTabId !== 'string' || typeof record.outputTabId !== 'string' ||
      typeof record.attemptedAt !== 'string' || typeof record.outputId !== 'string') {
    throw new Error('Unexpected report record. Preserve it and investigate.');
  }
  requireReportId_(record.templateId, 'record.templateId');
  requireReportId_(record.destinationFolderId, 'record.destinationFolderId');
  requireReportText_(record.outputName, 'record.outputName', 80);
  if (!Array.isArray(record.paragraphs) || record.paragraphs.length !== 5 ||
      !Array.isArray(record.rows) || record.rows.length < 2 || record.rows.length > 4) {
    throw new Error('The saved report plan has an unexpected size. Preserve it.');
  }
  record.paragraphs.forEach((text) => requireReportText_(text, 'saved paragraph', 500));
  for (const row of record.rows) {
    if (!Array.isArray(row) || row.length !== 3) throw new Error('A saved table row must have three cells.');
    row.forEach((text) => requireReportText_(text, 'saved table cell', 60));
  }
  if (record.status === 'copy_pending') {
    if (record.outputId !== '' || record.outputTabId !== '') throw new Error('Unexpected pending-copy identity.');
  } else {
    requireReportId_(record.outputId, 'record.outputId');
    if (record.outputId === record.templateId) throw new Error('The record identifies the template as the output.');
    if (record.status === 'complete' && record.outputTabId === '') throw new Error('The completed record lacks its tab ID.');
  }
  return record;
}

function requireSiteReportRecord_() {
  const record = readSiteReportRecord_();
  if (record === null) throw new Error('No report attempt is recorded. Recovery cannot invent an attempt.');
  return record;
}

/**
 * @param {SiteReportRecord} record
 * @returns {void}
 */
function requireSameReportSettings_(record) {
  if (record.templateId !== SITE_REPORT.templateId ||
      record.destinationFolderId !== SITE_REPORT.destinationFolderId ||
      record.outputName !== SITE_REPORT.outputName) {
    throw new Error('Settings differ from the recorded attempt. Restore them before proceeding.');
  }
}

Data.gs
// Fictional data for one short report. No spreadsheet or external API is used.
const SITE_REPORT_DATA = {
  project: 'Harbor garden',
  reporter: 'Rowan',
  visitDate: '2026-09-11',
  summary: 'Twelve planters checked. Keep $25 for labels; file the sketch in Plans\\West.',
  measurements: [
    { area: 'North bed', planters: 6, note: 'Ready' },
    { area: 'Path edge', planters: 2, note: 'Needs soil' },
    { area: 'Courtyard', planters: 4, note: 'Water first' }
  ]
};

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/documents",
    "https://www.googleapis.com/auth/drive"
  ]
}

template.txt

Download file
template.txt
{{Project}} site report
Prepared for {{Project}} by {{Reporter}}.
Visit: {{VisitDate}}
{{Summary}}
Measurements