lucgumshoe

Google Docs – Auto Renumber + Format Chapters Script

Nov 16th, 2025
217
0
344 days
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
JavaScript 10.37 KB | Source Code | 0 0
  1. function onOpen() {
  2.   DocumentApp.getUi()
  3.     .createMenu('Chapter Tools')
  4.     .addItem('Auto Format Chapters', 'autoFormatChapters')
  5.     .addToUi();
  6. }
  7.  
  8. function autoFormatChapters() {
  9.   const ui = DocumentApp.getUi();
  10.   const doc = DocumentApp.getActiveDocument();
  11.   const body = doc.getBody();
  12.   const paragraphs = body.getParagraphs();
  13.  
  14.   // Regex for a valid chapter line
  15.   const chapterRegex = /^\s*(chapter)\s+([0-9]+(?:\.[0-9]+)?)\s*([:–—-])\s*(.+?)\s*$/i;
  16.   const dividerCharsRegex = /[:–—-]/g;
  17.  
  18.   // Regex for text message format: Name: message
  19.   const textMessageRegex = /^(\S+):\s/;
  20.  
  21.   const errors = [];
  22.   const warnings = [];
  23.  
  24.   /** Info for each valid chapter line we find */
  25.   const chapterLines = []; // { paragraph, originalText, detectedNumber, title, index }
  26.   const chapterIndices = new Set(); // Track which paragraph indices are valid chapters
  27.  
  28.   let lastChapterNum = -Infinity; // for non-decreasing check
  29.  
  30.   // -------- FIRST PASS: VALIDATION ONLY (NO MUTATIONS) --------
  31.   for (let i = 0; i < paragraphs.length; i++) {
  32.     const p = paragraphs[i];
  33.     const text = p.getText();
  34.     const trimmed = text.trim();
  35.     if (!trimmed) continue; // skip empty lines
  36.  
  37.     // Check for any line starting with "CHAPTER" (case-insensitive)
  38.     const startsWithChapter = trimmed.toUpperCase().startsWith('CHAPTER');
  39.  
  40.     const match = chapterRegex.exec(text);
  41.  
  42.     if (match) {
  43.       // This is a candidate chapter line
  44.       const chapterWord = match[1]; // "chapter"
  45.       const numStr = match[2];      // original number
  46.       const divider = match[3];     // :, -, –, —
  47.       const title = match[4];       // rest of line
  48.  
  49.       // 1) Ensure exactly ONE divider in the whole line
  50.       const dividerMatches = text.match(dividerCharsRegex) || [];
  51.       if (dividerMatches.length !== 1) {
  52.         errors.push(
  53.           `Line ${i + 1}: chapter line has ${dividerMatches.length} divider characters (expected exactly 1): "${text}"`
  54.         );
  55.         continue;
  56.       }
  57.  
  58.       // 2) Parse chapter number as float
  59.       const chNum = parseFloat(numStr);
  60.       if (!isFinite(chNum)) {
  61.         errors.push(`Line ${i + 1}: invalid chapter number "${numStr}" in line: "${text}"`);
  62.         continue;
  63.       }
  64.  
  65.       // 3) Range limitation
  66.       if (!(chNum > 0.0 && chNum < 1000000.0)) {
  67.         errors.push(
  68.           `Line ${i + 1}: chapter number ${chNum} out of allowed range (0.0 < n < 1000000.0): "${text}"`
  69.         );
  70.         continue;
  71.       }
  72.  
  73.       // 4) Non-decreasing order check
  74.       if (chNum + 1e-9 < lastChapterNum) {
  75.         errors.push(
  76.           `Line ${i + 1}: chapter number ${chNum} is less than previous chapter number ${lastChapterNum}: "${text}"`
  77.         );
  78.         continue;
  79.       }
  80.       if (chNum > lastChapterNum) {
  81.         lastChapterNum = chNum;
  82.       }
  83.  
  84.       // 5) Title must be non-empty after trim
  85.       if (!title.trim()) {
  86.         errors.push(`Line ${i + 1}: chapter line has no title after divider: "${text}"`);
  87.         continue;
  88.       }
  89.  
  90.       chapterLines.push({
  91.         index: i,
  92.         paragraph: p,
  93.         originalText: text,
  94.         detectedNumber: chNum,
  95.         title: title.trim()
  96.       });
  97.      
  98.       chapterIndices.add(i);
  99.  
  100.     } else if (startsWithChapter) {
  101.       // A line starts with "CHAPTER" but did NOT match our strict pattern
  102.       errors.push(
  103.         `Line ${i + 1}: starts with "CHAPTER" but is not a valid chapter heading (check formatting): "${text}"`
  104.       );
  105.     }
  106.  
  107.     // Any Heading 1 that isn't a valid chapter line -> warning
  108.     if (p.getHeading() === DocumentApp.ParagraphHeading.HEADING1 && !match) {
  109.       warnings.push(
  110.         `Line ${i + 1}: Heading 1 paragraph that is not a valid chapter heading (will be removed): "${text}"`
  111.       );
  112.     }
  113.   }
  114.  
  115.   // If any errors, show ONE dialog and abort without changes
  116.   if (errors.length > 0) {
  117.     const msg =
  118.       'Auto Format Chapters aborted due to errors.\n\n' +
  119.       errors.join('\n') +
  120.       '\n\nPlease fix these issues and run again. No changes were applied.';
  121.     ui.alert('Auto Format Chapters – Errors Found', msg, ui.ButtonSet.OK);
  122.     return;
  123.   }
  124.  
  125.   // -------- SECOND PASS: APPLY CHANGES --------
  126.   // 1) Set whole document font to Verdana 12, no exceptions
  127.   const text = body.editAsText();
  128.   if (text) {
  129.     const len = text.getText().length;
  130.     if (len > 0) {
  131.       text.setFontFamily(0, len - 1, 'Verdana');
  132.       text.setFontSize(0, len - 1, 12);
  133.     }
  134.   }
  135.  
  136.   // 2) Handle all Heading 1 formatting: apply ONLY to valid chapters, remove from others
  137.   for (let i = 0; i < paragraphs.length; i++) {
  138.     const p = paragraphs[i];
  139.    
  140.     if (chapterIndices.has(i)) {
  141.       // This is a valid chapter - ensure it has Heading 1
  142.       p.setHeading(DocumentApp.ParagraphHeading.HEADING1);
  143.     } else if (p.getHeading() === DocumentApp.ParagraphHeading.HEADING1) {
  144.       // This has Heading 1 but is NOT a valid chapter - remove it
  145.       p.setHeading(DocumentApp.ParagraphHeading.NORMAL);
  146.     }
  147.   }
  148.  
  149.   // 3) Renumber chapters sequentially: Chapter 1: Title, Chapter 2: Title, ...
  150.   let chapterCounter = 0;
  151.   for (let i = 0; i < chapterLines.length; i++) {
  152.     const info = chapterLines[i];
  153.     const p = info.paragraph;
  154.     const newNum = ++chapterCounter;
  155.     const newText = `Chapter ${newNum}: ${info.title}`;
  156.  
  157.     // Replace text
  158.     p.setText(newText);
  159.   }
  160.  
  161.   // 4) Split paragraphs containing soft line breaks (\n or \r) into real paragraphs
  162.   for (let i = paragraphs.length - 1; i >= 0; i--) {
  163.     const p = paragraphs[i];
  164.     let text = p.getText();
  165.  
  166.     if (text.indexOf('\n') === -1 && text.indexOf('\r') === -1) continue; // no soft breaks → nothing to do
  167.  
  168.     // Split on line breaks and trim each resulting line
  169.     const parts = text.replace(/\r/g, '\n').split('\n').map(s => s.trim());
  170.  
  171.     let insertIndex = i + 1; // where to insert new paragraphs
  172.    
  173.     // Handle the first part
  174.     if (parts[0] !== "") {
  175.       p.setText(parts[0]);
  176.     } else {
  177.       // First part is empty - try to delete original paragraph
  178.       try {
  179.         body.removeChild(p);
  180.         insertIndex = i; // We deleted it, so insert at i, not i+1
  181.       } catch (e) {
  182.         // Can't delete - leave it with empty/original text
  183.       }
  184.     }
  185.  
  186.     // Insert subsequent parts as new paragraphs
  187.     for (let j = parts.length - 1; j >= 1; j--) {
  188.       if (parts[j] !== "") {
  189.         const newP = body.insertParagraph(insertIndex, parts[j]);
  190.         newP.setHeading(p.getHeading());
  191.         newP.setAttributes(p.getAttributes());
  192.       }
  193.     }
  194.   }
  195.  
  196.   // 5) Trim whitespace & collapse repeated spaces for all non-chapter paragraphs
  197.   for (let i = paragraphs.length - 1; i >= 0; i--) {
  198.     const p = paragraphs[i];
  199.     let text = p.getText();
  200.     const original = text;
  201.     // Trim leading/trailing whitespace
  202.     text = text.trim();
  203.     if (text !== original && text !== "") {
  204.       p.setText(text);
  205.     }
  206.   }
  207.  
  208.  
  209.   // -------- THIRD PASS: WARNINGS AND TEXT MESSAGE FORMATTING --------
  210.   // Get fresh paragraph list after modifications
  211.   const updatedParagraphs = body.getParagraphs();
  212.  
  213.   let prevParagraphBold = false;
  214.   let prevParagraphItalic = false;
  215.   let prevIndex = -1;
  216.  
  217.   for (let i = 0; i < updatedParagraphs.length; i++) {
  218.     const p = updatedParagraphs[i];
  219.     const text = p.getText();
  220.     const trimmed = text.trim();
  221.    
  222.     if (!trimmed) {
  223.       prevParagraphBold = false;
  224.       prevParagraphItalic = false;
  225.       prevIndex = -1;
  226.       continue;
  227.     }
  228.    
  229.     // Check if entire paragraph is bold or italic
  230.     // OPTIMIZATION: Sample only 5 positions instead of checking every character
  231.     const editText = p.editAsText();
  232.     const len = text.length;
  233.    
  234.     let entirelyBold = true;
  235.     let entirelyItalic = true;
  236.    
  237.     if (len > 0) {
  238.       // Sample at most 5 positions: start, 25%, 50%, 75%, end
  239.       const samplePositions = [
  240.         0,
  241.         Math.floor(len * 0.25),
  242.         Math.floor(len * 0.5),
  243.         Math.floor(len * 0.75),
  244.         len - 1
  245.       ];
  246.       // Remove duplicates for short paragraphs
  247.       const uniquePositions = [...new Set(samplePositions)];
  248.      
  249.       for (let pos of uniquePositions) {
  250.         if (!editText.isBold(pos)) entirelyBold = false;
  251.         if (!editText.isItalic(pos)) entirelyItalic = false;
  252.         if (!entirelyBold && !entirelyItalic) break; // Early exit
  253.       }
  254.     } else {
  255.       entirelyBold = false;
  256.       entirelyItalic = false;
  257.     }
  258.    
  259. // Check for consecutive bold paragraphs
  260.     if (entirelyBold && prevParagraphBold) {
  261.       const prevText = updatedParagraphs[prevIndex].getText();
  262.       warnings.push(
  263.         `Lines ${prevIndex + 1} and ${i + 1}: consecutive paragraphs are entirely bold (possible copy-paste error)\n` +
  264.         `  "${prevText.substring(0, 50)}${prevText.length > 50 ? '...' : ''}"\n` +
  265.         `  "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`
  266.       );
  267.     }
  268.    
  269.     // Check for consecutive italic paragraphs
  270.     if (entirelyItalic && prevParagraphItalic) {
  271.       const prevText = updatedParagraphs[prevIndex].getText();
  272.       warnings.push(
  273.         `Lines ${prevIndex + 1} and ${i + 1}: consecutive paragraphs are entirely italics (possible copy-paste error)\n` +
  274.         `  "${prevText.substring(0, 50)}${prevText.length > 50 ? '...' : ''}"\n` +
  275.         `  "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`
  276.       );
  277.     }
  278.    
  279.     prevParagraphBold = entirelyBold;
  280.     prevParagraphItalic = entirelyItalic;
  281.     prevIndex = i;
  282.    
  283.     // Check for text message format and apply bold to label
  284.     const msgMatch = textMessageRegex.exec(text);
  285.     if (msgMatch) {
  286.       const label = msgMatch[1] + ':'; // e.g., "Natalie:"
  287.       const labelLen = label.length;
  288.      
  289.       // Check if label is already bold (check first and last char only)
  290.       const labelIsBold = editText.isBold(0) && (labelLen === 1 || editText.isBold(labelLen - 1));
  291.      
  292.       if (!labelIsBold) {
  293.         // Apply bold to the label
  294.         editText.setBold(0, labelLen - 1, true);
  295.       }
  296.     }
  297.   }
  298.  
  299.   // Show warnings or success message
  300.   if (warnings.length > 0) {
  301.     const msg =
  302.       'Auto Format Chapters completed with warnings.\n\n' +
  303.       warnings.join('\n');
  304.     ui.alert('Auto Format Chapters – Warnings', msg, ui.ButtonSet.OK);
  305.   } else {
  306.     ui.alert('Auto Format Chapters', 'Completed successfully with no warnings.', ui.ButtonSet.OK);
  307.   }
  308. }
Advertisement
Add Comment
Please, Sign In to add comment