Friday, 30 January 2026

Google Docs Appscript GeneralFunctions 2026

 /**

 * Runs automatically when the document is opened.
 */
function onOpen() {
  DocumentApp.getUi()
    .createMenu('TOC Tools')
    .addItem('Update Table of Contents Now', 'updateTOC')
    .addToUi();

  updateTOC();  // Auto-update on open
}


/**
/ ======================================================================================
/ ======================================================================================
/ AK TABLE OF CONTENTS TOOLS!!!!!! CAN RUN ON OPN
/ AK TABLE OF CONTENTS TOOLS!!!!!! CAN RUN ON OPN



function onOpen() {
  DocumentApp.getUi()
    .createMenu('TOC Tools')
    .addItem('Update Table of Contents Now', 'updateTOC')
    .addToUi();

  updateTOC();  // Auto-update on open
}







*/

/**
 * Builds or updates the Table of Contents at the top.
 */
function updateTOC() {
  const doc = DocumentApp.getActiveDocument();
  const body = doc.getBody();

  removeExistingTOC(body);

  const headings = collectHeadings(body);

  // Feedback (assuming you have a showToast function defined)
  if (headings.length === 0) {
    showToast("No headings (Heading 1–3) found. Check paragraph styles.", "TOC Update", 10);
  } else {
    showToast(`Found ${headings.length} headings. TOC updated.`, "TOC Update", 5);
  }

  if (headings.length === 0) return;

  // Insert title (already black and centered)
  // Insert title
    const tocTitle = body.insertParagraph(0, 'Table of Contents');
    tocTitle.setHeading(DocumentApp.ParagraphHeading.HEADING1)
            .setAlignment(DocumentApp.HorizontalAlignment.CENTER)
            .setAttributes({
              BOLD: true,
              FONT_SIZE: 18,
              FOREGROUND_COLOR: '#000000'
            });

    // Set Calibri for the title too
    const titleText = tocTitle.editAsText();
    titleText.setFontFamily(0, titleText.getText().length - 1, 'Calibri');
     
  body.insertParagraph(1, ''); // blank line

  let insertPos = 2;

  headings.forEach(heading => {
    const p = body.insertParagraph(insertPos++, heading.text.trim());
   
    const indent = (heading.level - 1) * 36;
    p.setIndentStart(indent)
    .setIndentEnd(0)
    .setIndentFirstLine(0);

    // Get the text object for fine-grained control
    const textObj = p.editAsText();  // This gives access to the full text range
    const fullLength = textObj.getText().length;

    // Set font family to Calibri for the entire entry
    textObj.setFontFamily(0, fullLength - 1, 'Calibri');

    // Apply black color and NO underline to the entire text
    textObj.setForegroundColor(0, textObj.getText().length - 1, '#000000');
    textObj.setUnderline(0, textObj.getText().length - 1, false);

    // NOW set the link — Docs will try to force blue/underline, but we override after
    textObj.setLinkUrl(0, textObj.getText().length - 1, '#bookmark=' + heading.bookmarkId);

    // Critical override: re-apply styles AFTER setting the link
    textObj.setForegroundColor(0, textObj.getText().length - 1, '#000000');
    textObj.setUnderline(0, textObj.getText().length - 1, false);
  });

  body.insertHorizontalRule(insertPos);
}

function showToast(message, title = "Info", timeoutSeconds = 4) {
  const html = HtmlService.createHtmlOutput(
    `<div style="padding: 12px; font-family: Arial; font-size: 14px;">
       <strong>${title}</strong><br>
       ${message}
     </div>`
  )
  .setWidth(280)
  .setHeight(80);

  const ui = DocumentApp.getUi();
  ui.showModelessDialog(html, " ");  // empty title bar looks cleaner

  // Auto-close after timeout (client-side JS)
  if (timeoutSeconds > 0) {
    html.append(`<script>
      setTimeout(() => google.script.host.close(), ${timeoutSeconds * 1000});
    </script>`);
  }
}




function removeExistingTOC(body) {
  const toRemove = [];
  let foundTitle = false;

  showToast("Starting TOC cleanup scan...", "Debug", 3);

  for (let i = 0; i < body.getNumChildren(); i++) {
    const child = body.getChild(i);

    if (child.getType() !== DocumentApp.ElementType.PARAGRAPH) {
      // Remove HR or other non-paragraph if after title
      if (foundTitle) {
        toRemove.push(i);
      }
      continue;
    }

    const p = child.asParagraph();
    const textTrim = p.getText().trim();
    const textLower = textTrim.toLowerCase();
    const isHeading = p.getHeading() !== DocumentApp.ParagraphHeading.NORMAL_TEXT;

    // Detect title (very forgiving)
    if (!foundTitle && textLower.includes('table') && textLower.includes('contents')) {
      foundTitle = true;
      toRemove.push(i);
      showToast("TOC title found at position " + i, "Debug", 4);
      continue;
    }

    if (foundTitle) {
      // Keep removing while:
      // - blank
      // - OR has link (bookmark link)
      // - OR indented
      // - OR NOT a heading
      const textObj = p.editAsText();
      const hasLink = textObj.getText().length > 0 && textObj.getLinkUrl() !== null;
      const isIndented = p.getIndentStart() > 0;
      const isBlank = textTrim === '';

      if (isBlank || hasLink || isIndented || !isHeading) {
        toRemove.push(i);
        showToast("Removing TOC-like item at " + i + " (link=" + hasLink + ", indent=" + isIndented + ")", "Debug", 2);
      } else {
        // This looks like your first real section heading → STOP and DO NOT remove it
        showToast("Stop at position " + i + " — real heading detected", "Debug", 5);
        break;
      }

      if (toRemove.length > 60) break; // safety
    }
  }

  if (toRemove.length > 0) {
    showToast("Preparing to remove " + toRemove.length + " items", "Info", 4);
    toRemove.sort((a, b) => b - a);
    toRemove.forEach(idx => {
      try {
        body.removeChild(body.getChild(idx));
      } catch (e) {
        showToast("Remove failed at " + idx + ": " + e.message, "Error", 6);
      }
    });
    showToast("Cleanup finished — removed " + toRemove.length + " items", "Success", 5);
  } else {
    showToast("No TOC detected at top — nothing removed", "Warning", 6);
  }
}


/**
 * Collect headings with reliable bookmark handling
 */
/**
 * Collect headings with reliable bookmark handling (no findElement on Body)
 */
function collectHeadings(body) {
  const doc = DocumentApp.getActiveDocument();
  const headings = [];

  for (let i = 0; i < body.getNumChildren(); i++) {
    const child = body.getChild(i);
    if (child.getType() !== DocumentApp.ElementType.PARAGRAPH) continue;

    const p = child.asParagraph();
    const headingType = p.getHeading();

    if (![DocumentApp.ParagraphHeading.HEADING1,
          DocumentApp.ParagraphHeading.HEADING2,
          DocumentApp.ParagraphHeading.HEADING3].includes(headingType)) continue;

    const text = p.getText().trim();
    if (text === '') continue;

    // Get the Text element reliably (most paragraphs have exactly one Text child)
    let textElement = null;
    if (p.getNumChildren() > 0) {
      const potentialText = p.getChild(0);
      if (potentialText.getType() === DocumentApp.ElementType.TEXT) {
        textElement = potentialText.asText();
      }
    }

    // Fallback: If no Text child (very rare for headings), use editAsText() which gives a Text element
    if (!textElement) {
      textElement = p.editAsText();
    }

    // Now create position at offset 0 (start of the text)
    const position = doc.newPosition(textElement, 0);

    const bookmark = doc.addBookmark(position);
    headings.push({
      level: getHeadingLevel(headingType),
      text: text,
      bookmarkId: bookmark.getId()
    });
  }

  return headings;
}
function getHeadingLevel(heading) {
  switch (heading) {
    case DocumentApp.ParagraphHeading.HEADING1: return 1;
    case DocumentApp.ParagraphHeading.HEADING2: return 2;
    case DocumentApp.ParagraphHeading.HEADING3: return 3;
    default: return 0;
  }
}

/**
/ ======================================================================================
/ ======================================================================================
/ END AK TABLE OF CONTENTS TOOLS!!!!!! CAN RUN ON OPN
/ END AK TABLE OF CONTENTS TOOLS!!!!!! CAN RUN ON OPN */