appscript.dev
Automation Intermediate Docs Drive Sheets

Index every heading across a folder of Docs

Build a searchable outline of all documents in a folder — find any section by name.

Published Sep 21, 2025

Northwind’s knowledge-base/ folder holds around 50 Docs — process notes, style guides, onboarding pages. The content is good, but finding the right section means opening Docs one by one and scanning their outlines. There is no single place to search “where do we cover invoicing?” and get an answer.

This script builds that place. It walks every Doc in a folder, pulls out each heading paragraph with its level and a link back to the source, and writes the lot to a sheet. The result is a flat, filterable outline of the whole knowledge base — sort it, search it, or filter to “Heading 1” for a table of contents across all 50 documents at once.

What you’ll need

  • A Drive folder of Google Docs to index, and its folder ID.
  • A Google Sheet to write the index into, and its sheet ID. The script writes to the first tab and clears it first, so use a dedicated sheet.
  • Read access to the Docs and edit access to the sheet.

The script

// The Drive folder whose Docs should be indexed.
const FOLDER_ID = '1abcKnowledgeBaseFolderId';

// The Sheet the heading index is written to.
const INDEX_SHEET_ID = '1abcIndexSheetId';

// The header row written above the index.
const HEADERS = ['Doc', 'Level', 'Heading', 'Link'];

/**
 * Indexes every heading in every Doc of a folder into a sheet.
 *
 * @param {string} folderId The folder of Docs to scan.
 * @param {string} sheetId The Sheet to write the index into.
 */
function indexHeadings(folderId, sheetId) {
  // 1. Open the folder and grab an iterator of its Google Docs.
  const files = DriveApp.getFolderById(folderId)
    .getFilesByType(MimeType.GOOGLE_DOCS);

  const rows = [];

  // 2. Walk every Doc in the folder.
  while (files.hasNext()) {
    const file = files.next();
    const paragraphs = DocumentApp.openById(file.getId())
      .getBody()
      .getParagraphs();

    // 3. Walk every paragraph, keeping only the headings.
    for (const p of paragraphs) {
      const h = p.getHeading();
      if (h === DocumentApp.ParagraphHeading.NORMAL) continue;

      // 4. Pull the numeric level out of the heading enum name.
      const level = h.toString().match(/HEADING(\d)/)?.[1] || '?';

      // 5. Record the Doc name, level, heading text, and a link back.
      rows.push([file.getName(), level, p.getText(), file.getUrl()]);
    }
  }

  // 6. Bail out if no headings were found anywhere.
  if (!rows.length) {
    Logger.log('No headings found in this folder — nothing to index.');
    return;
  }

  // 7. Rebuild the index sheet from scratch and write every heading.
  const sheet = SpreadsheetApp.openById(sheetId).getSheets()[0];
  sheet.clear();
  sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
  sheet.getRange(2, 1, rows.length, HEADERS.length).setValues(rows);
  Logger.log('Indexed ' + rows.length + ' headings.');
}

How it works

  1. indexHeadings opens the folder and asks for an iterator filtered to Google Docs only, so spreadsheets and PDFs in the folder are ignored.
  2. It loops over every Doc, opening each one and reading its body’s paragraphs.
  3. For each paragraph it calls getHeading(). Anything that comes back as NORMAL is body text and is skipped — only true headings make the index.
  4. The heading enum stringifies to names like HEADING2. A small regex pulls the digit out so the index records a plain 2 rather than the enum name. TITLE and SUBTITLE headings have no digit, so they record as ?.
  5. It pushes one row per heading: the Doc name, the level, the heading text, and file.getUrl() — the link that lets you jump straight to the source Doc.
  6. If no headings turned up across the whole folder it logs and stops, rather than clearing the sheet for nothing.
  7. It clears the first tab, writes the header row, and writes every collected heading in one setValues call.

Example run

Say knowledge-base/ contains two Docs. “Invoicing process” has a Heading 1 and two Heading 2s; “Onboarding guide” has a Heading 1 and one Heading 2. After a run the index sheet holds:

DocLevelHeadingLink
Invoicing process1Raising an invoicedocs.google.com/…
Invoicing process2Approval stepsdocs.google.com/…
Invoicing process2Chasing late paymentdocs.google.com/…
Onboarding guide1First daydocs.google.com/…
Onboarding guide2Accounts and accessdocs.google.com/…

Now a search for “payment” finds the section instantly, and filtering the Level column to 1 gives a top-level map of the whole knowledge base.

Trigger it

The knowledge base changes slowly, so a nightly rebuild keeps the index fresh without any manual effort:

  1. In the Apps Script editor open Triggers (the clock icon).
  2. Click Add Trigger.
  3. Choose indexHeadings, event source Time-driven, type Day timer, and a quiet hour such as 2am to 3am.

Because the function takes folderId and sheetId as arguments, a trigger needs a zero-argument wrapper:

/**
 * Trigger entry point — indexes the configured folder into the index sheet.
 */
function indexHeadingsScheduled() {
  indexHeadings(FOLDER_ID, INDEX_SHEET_ID);
}

Point the trigger at indexHeadingsScheduled instead of indexHeadings.

Watch out for

  • Opening every Doc is slow. Around 50 Docs is comfortable, but a few hundred may bump into the six-minute execution limit — index in batches by sub-folder if the folder keeps growing.
  • The script reads the folder’s direct children only. Docs in sub-folders are not indexed; recurse into sub-folders explicitly if your knowledge base is nested.
  • getUrl() links to the Doc, not to the specific heading. Apps Script cannot produce a deep link to a heading, so the index gets you to the right document — the reader scrolls from there.
  • TITLE and SUBTITLE paragraphs index with a level of ?. If you only want Heading 1–6, filter those rows out before pushing them.

Related