Skip to content
Skip to chapter

Chapter 15

Workspace services

Generate a Docs report from a template

Copy a Docs template with DriveApp, fill its placeholders and add a formatted table with DocumentApp, and save the report ID so reruns skip the copy.

By the end

Generate one formatted fictional site report in a private folder, leave its template unchanged, and rerun the script without creating a second copy.

Chapter navigation

Generate one report from a template

Copy a Google Docs template into a Drive folder, fill its placeholders from the report data in Data.gs, and append a formatted measurements table. Then save the new document's ID in Script Properties so later runs do not create a second report. You need a Google account, an empty folder in My Drive, and the Drive basics from Choose and copy Drive files.

DriveApp copies the template file into the destination folder. DocumentApp reads the template paragraphs and writes the report body in the copy. The template must have one document tab with five paragraphs. The script appends a table of four rows and three columns (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 checks that the document has one top-level tab with no child tabs before selecting tabs[0].asDocumentTab(). In a document with several tabs, select the target tab by ID with document.getTab(tabId), which returns null if no tab has that ID. The older top-level document.getBody() reaches only the active or first tab.

Within a tab's Body, getChild(index) returns one structural element. The project's prepareReport_ helper checks element.getType() before calling asParagraph(). A Paragraph controls block properties such as heading style and line spacing, while its Text child controls character formatting such as font size and color. 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. Its summary field includes $25 and a literal backslash (\\ in JavaScript source, rendered as \ in the document) so you can verify that the script inserts special characters 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 it calls Drive or Docs, the script checks that templateId and destinationFolderId are IDs: not empty, not the REPLACE_... placeholders, and not 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.

Replace placeholders with literal text

prepareReport_ 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.

The script checks each paragraph's placeholders. It stops if a required placeholder is missing, if the paragraph contains a placeholder that does not belong to it, or if a {{ or }} is not part of a complete placeholder. The callback passed to replace returns the replacement text directly. replace inserts a value such as $& as those literal characters and does not scan the returned text again for placeholders.

Body.replaceText and Text.replaceText work differently. They take an RE2 regular-expression string, so literal braces and dots must be escaped ('\\{\\{Project\\}\\}'), and they match within individual text runs. This project rebuilds the five plain paragraphs and formats them afterward, so it does not depend on how Docs splits the template into runs.

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, saves a record in Script Properties with the prepared paragraphs and table rows, and only then calls makeCopy(outputName, destination). When Drive returns the new file, the script saves the copy's outputId. It then opens the copy with DocumentApp.openById(outputId) and writes the 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.

renderRecordedReport_ writes and formats the copy. It replaces the body text with body.setText(...), appends the other paragraphs, and sets the TITLE and HEADING2 headings and the spacing after each paragraph. paragraph.editAsText() sets the title to 20 pt dark green, the section heading to 14 pt, and the body text to 11 pt. See Body, Paragraph, and Text.

It then formats the table cells. table.getCell(row, column) counts from zero, so the header is row 0. The script gives the header row a pale green background and bold text, and gives the table a 1 pt border. See TableCell. appendTable leaves an empty paragraph after the table because a Google Doc body cannot end with a table.

Understand the saved record before repeating

The project stores its saved record as a JSON string under the key DOCS_SITE_REPORT in PropertiesService.getScriptProperties() (see the Properties service guide). The record includes the prepared paragraphs and table rows.

Recorded statusWhat you knowWhat you still need to check
No recordThis project has not saved a record.Whether a same-named file already exists in the destination folder (the initial name check stops if one is present).
copy_pendingThe prepared paragraphs and table rows were saved before makeCopy() was called.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). Without the lock, two runs started at the same time could 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 without making another copy

Copying in Drive, editing in Docs, and updating Script Properties are three separate operations, and any of them can fail while the others have already happened. document.saveAndClose() applies pending Docs changes, and Apps Script also applies them when an execution ends. An error partway through formatting can therefore leave a half-written copy.

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

  • If status is copy_pending, check whether Drive created the copy. Look in Site report practice outputs and in the execution log, which prints Drive returned output ID immediately after makeCopy(). 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. It then sets the status saved in 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. It rewrites and formats the confirmed document from the saved record 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 function removes the added line, restores the saved five paragraphs and table in the same document, and sets the DOCS_SITE_REPORT status back 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 saved recordRestore templateId, destinationFolderId, and outputName to match showSiteReportRecord.
Runtime, service, or quota failure after a report run startsRun showSiteReportRecord and follow the copy_pending or editing recovery path above.

When you finish the exercise, move the generated report, Site report template, the Site report practice outputs folder, and the Site report practice script project to Trash. You can also revoke the project's OAuth grant in your Google Account settings.

Know when to use the advanced APIs

DocumentApp and DriveApp are enough to copy a file in My Drive and to fill and format a single-tab document.

Enable the advanced Docs service (Docs) when you need:

  • Several edits in one documents.batchUpdate request that succeeds or fails as a whole.

  • The number of replacements made.

  • A write that fails if someone edited the document since you read it (writeControl.requiredRevisionId).

With the Docs REST API and a document that has several tabs, pass includeTabsContent: true on reads and name the target tab IDs on writes. Some REST operations default to the first tab, while others (replaceAllText) can span all tabs when no tab filter is given (see the Docs API tabs guide).

Enable the advanced Drive service (Drive) when you need shared drives, custom file properties, or Drive revision metadata.

Next, Prepare Gmail work before sending.

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