← BACK TO HOME

Google Forms → Narrative Doc Generator

Apps Script for transforming form responses into readable documents

i have a google form collecting responses from a post webinar survey. i want those questions and responses neatly copied from sheets into narrative format so i can read each on a page or 2 in a larger doc. i can't read that stuff in sheets...it's miserable. is there a pre built way of doing this with google forms or something i need to build or do manually?

Code.gs

/****  Google Forms → Narrative Doc Generator
 *    Author: You :)
 *    WARNING: all risks assumed by the person using this script
 *    What it does:
 *      - Reads a Google Sheets "Form responses" sheet
 *      - Builds a readable Google Doc
 *      - Two layouts:
 *          1) GROUP_BY: 'question'  (sections per question, all answers underneath)
 *          2) GROUP_BY: 'response'  (sections per respondent, Q→A in order)
 *    Notes:
 *      - No third-party add-ons required.
 *      - Safe defaults; tweak CONFIG to taste.
 ****/

const CONFIG = {
  SHEET_NAME: 'Form Responses 1',     // ← change if yours is different
  GROUP_BY: 'question',               // 'question' or 'response'
  DOC_TITLE_PREFIX: 'Feedback: DIM SUM  X VIBE CODING (Responses)',
  EXCLUDE_COLUMNS: [                  // columns to ignore (edit as needed)
    // 'Timestamp', 'Email Address', 'Score'
  ],
  ANONYMIZE_RESPONDENTS: false,        // hides respondent details if columns like "Name", "Email Address" exist
  PAGE_BREAK_EVERY_N_QUESTIONS: 0,    // 0 = no forced breaks; or e.g. 3 = page break after every 3 questions
  PAGE_BREAK_EVERY_N_RESPONSES: 0,    // only used when GROUP_BY === 'response'
  INCLUDE_SUMMARY_HEADER: true,       // adds doc header with counts & date
  TRIM_WHITESPACE: true,              // trims answers
  SKIP_BLANK_ANSWERS: false,           // skip empty answers
  MAX_ANSWERS_PER_QUESTION: null,     // e.g. 50 to limit; null = no limit
  // If your multi-select answers use a delimiter, you can split & bullet them:
  MULTISELECT_SPLIT: null             // e.g. ',' or ';' | null = don't split
};

function generateNarrativeDoc() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
  if (!sheet) throw new Error(`Sheet "${CONFIG.SHEET_NAME}" not found.`);

  const values = sheet.getDataRange().getValues();
  if (!values || values.length < 2) throw new Error('No response rows found.');

  const headers = values[0].map(String);
  const dataRows = values.slice(1).filter(row => row.some(cell => String(cell).trim() !== ''));

  // Build a working set of columns we'll include
  const includeIdx = headers
    .map((h, i) => ({ i, h }))
    .filter(col => !CONFIG.EXCLUDE_COLUMNS.includes(col.h));

  if (includeIdx.length === 0) throw new Error('After exclusions, no columns remain to format.');

  // Prepare the doc
  const docTitle = `${CONFIG.DOC_TITLE_PREFIX} — ${Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm')}`;
  const doc = DocumentApp.create(docTitle);
  const body = doc.getBody();
  body.clear();

  // Header / Summary
  if (CONFIG.INCLUDE_SUMMARY_HEADER) {
    body.appendParagraph(CONFIG.DOC_TITLE_PREFIX).setHeading(DocumentApp.ParagraphHeading.HEADING1);
    body.appendParagraph(`Generated: ${Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'EEE, MMM d, yyyy h:mm a')}`)
        .setSpacingAfter(4);
    body.appendParagraph(`Responses: ${dataRows.length}`).setSpacingAfter(14);
    body.appendHorizontalRule();
  }

  if (CONFIG.GROUP_BY.toLowerCase() === 'question') {
    buildByQuestion(body, headers, includeIdx, dataRows);
  } else if (CONFIG.GROUP_BY.toLowerCase() === 'response') {
    buildByResponse(body, headers, includeIdx, dataRows);
  } else {
    throw new Error(`Unknown GROUP_BY "${CONFIG.GROUP_BY}". Use "question" or "response".`);
  }

  // Tidy formatting
  body.editAsText().setFontFamily('Arial');
  doc.saveAndClose();

  Logger.log(`✅ Created: ${doc.getUrl()}`);
}

/** GROUPING: One section per question, with all answers listed beneath (fixed) */
function buildByQuestion(body, headers, includeIdx, dataRows) {
  // Map question -> array of answers
  const qMap = new Map();
  includeIdx.forEach(col => qMap.set(col.h, []));

  dataRows.forEach((row) => {
    includeIdx.forEach(({ i, h }) => {
      let val = String(row[i] ?? '');
      if (CONFIG.TRIM_WHITESPACE) val = val.trim();
      if (CONFIG.SKIP_BLANK_ANSWERS && !val) return;

      // Anonymize respondent-identifying questions if desired
      if (CONFIG.ANONYMIZE_RESPONDENTS && looksLikePIIHeader(h)) {
        val = '[redacted]';
      }

      qMap.get(h).push(val);
    });
  });

  let questionCounter = 0;
  includeIdx.forEach(({ h }) => {
    questionCounter++;
    const answers = qMap.get(h) || [];
    body.appendParagraph(h).setHeading(DocumentApp.ParagraphHeading.HEADING2);

    if (answers.length === 0) {
      body.appendParagraph('— No responses —').setItalic(true).setSpacingAfter(12);
    } else {
      const max = CONFIG.MAX_ANSWERS_PER_QUESTION ?? answers.length;
      const limit = Math.min(answers.length, max);

      for (let idx = 0; idx < limit; idx++) {
        const ans = formatAnswer(answers[idx]);
        // Guard: Apps Script throws if you pass an empty string
        const safe = (ans && String(ans).trim().length > 0) ? String(ans) : '—';
        body.appendListItem(safe).setGlyphType(DocumentApp.GlyphType.BULLET);
      }
      body.appendParagraph('').setSpacingAfter(14);
    }

    // Optional page breaks
    if (CONFIG.PAGE_BREAK_EVERY_N_QUESTIONS > 0 &&
        questionCounter % CONFIG.PAGE_BREAK_EVERY_N_QUESTIONS === 0) {
      body.appendPageBreak();
    }
  });
}

/** GROUPING: One section per respondent, listing Q→A in order */
function buildByResponse(body, headers, includeIdx, dataRows) {
  dataRows.forEach((row, rIdx) => {
    body.appendParagraph(`Response #${rIdx + 1}`).setHeading(DocumentApp.ParagraphHeading.HEADING2);

    includeIdx.forEach(({ i, h }) => {
      let val = String(row[i] ?? '');
      if (CONFIG.TRIM_WHITESPACE) val = val.trim();
      if (CONFIG.SKIP_BLANK_ANSWERS && !val) return;

      // Anonymize typical PII column headers
      const displayVal = (CONFIG.ANONYMIZE_RESPONDENTS && looksLikePIIHeader(h)) ? '[redacted]' : val;

      // Question
      const qPara = body.appendParagraph(h);
      qPara.setHeading(DocumentApp.ParagraphHeading.HEADING3);

      // Answer (as paragraph or split bullets if MULTISELECT_SPLIT is set)
      if (CONFIG.MULTISELECT_SPLIT && displayVal.includes(CONFIG.MULTISELECT_SPLIT)) {
        const parts = displayVal.split(CONFIG.MULTISELECT_SPLIT).map(s => s.trim()).filter(Boolean);
        parts.forEach((p, idx) => {
          if (idx === 0) {
            const li = body.appendListItem(p).setGlyphType(DocumentApp.GlyphType.BULLET);
          } else {
            body.appendListItem(p).setGlyphType(DocumentApp.GlyphType.BULLET);
          }
        });
      } else {
        body.appendParagraph(displayVal).setSpacingAfter(10);
      }
    });

    // Optional page breaks
    if (CONFIG.PAGE_BREAK_EVERY_N_RESPONSES > 0 &&
        (rIdx + 1) % CONFIG.PAGE_BREAK_EVERY_N_RESPONSES === 0) {
      body.appendPageBreak();
    } else {
      body.appendParagraph('').setSpacingAfter(16);
      body.appendHorizontalRule();
    }
  });
}

/** Helpers **/

function looksLikePIIHeader(header) {
  const h = String(header).toLowerCase();
  const hints = ['email', 'name', 'first name', 'last name', 'phone', 'contact'];
  return hints.some(k => h.includes(k));
}

function formatAnswer(answer) {
  if (!CONFIG.MULTISELECT_SPLIT || !String(answer).includes(CONFIG.MULTISELECT_SPLIT)) {
    return String(answer);
  }
  // If you wanted to inline-summarize multi-selects, you could do it here.
  return String(answer);
}

/** Optional: time-based trigger to regenerate a fresh doc daily at ~2am
 *  1) Run once to create trigger; 2) Manage in Triggers UI to remove if needed.
 */
function installDailyTrigger() {
  ScriptApp.newTrigger('generateNarrativeDoc')
    .timeBased()
    .atHour(2)
    .everyDays(1)
    .create();
}