Auto-Rewrite Google Docs Based on Comments
AI-powered solution to rewrite you Google Doc based on comments and organizes questions into a FAQs.
Do you use Google Docs to gather feedback on your strategies or key documents? If so, this code is going to be a must-have! As you work through your 2025 planning, managing dozens of memos and navigating through countless comments can be time-consuming and frustrating. Valuable ideas can easily be overlooked, and keeping everything organized becomes a challenge.
This exact situation came up for me last week while finalizing my team’s strategy in a Google Doc filled with dozens of threads of comments. I thought, “It would be so helpful if AI could just rewrite this document based on the feedback.”
So I started experimenting with a google app script that uses AI to summarize the threads of comments into clear, actionable insights while keeping the final creative decisions in my control.
It also captures all questions into a dedicated FAQ, ensuring your team’s collective knowledge is preserved even after comments are closed. What used to feel like a tedious process now becomes an efficient way to gather and act on feedback effectively.
Imagine an AI solution that:
- Summarizes Comments and provides AI rewrite suggestions
- Auto-Generates FAQs from Questions
If you find this useful, see my collection of free AI solutions at https://genaisecretsauce.com
What Does This Code Do?
- Retrieves Comments from Your Google Doc: The script looks at the comments left in your current Google Doc. It pulls out unresolved and active comments, along with any replies, authors, and associated “quoted text” (text in the document that the commenter was referring to).
- Creates a Copy of Your Document: It generates a new copy of your existing Google Doc so you’re not editing the original. This copy is where the script will insert all the processed comments and additional content.
- Inserts Comments and Summaries Into the Document Copy: In the copied document, the script inserts neatly formatted tables containing the comments.
- Comment Tables: Display who made the comment and what they said.
- Generated Summaries (Using OpenAI): The script can send each comment and its related text to the OpenAI API. Based on the comment’s feedback, it produces a “rewritten” section that incorporates suggested improvements and makes the text more professional and polished.
4. Generates a FAQ Section: After processing all comments, the script sends the entire document’s content to the OpenAI API to create a helpful Frequently Asked Questions (FAQ) section. This makes it easier for readers to quickly understand the core ideas, challenges, and solutions presented in the document.
5. Easy Integration via Custom Menu: Once installed, you’ll see a “Comments” menu in your Google Doc. This allows you to run the script’s main functions directly from your doc — no need to dive back into the code once it’s set up.
Requirements Before You Begin
- OpenAI API Key: You’ll need an API key from OpenAI. If you don’t have one, sign up at https://openai.com/ and generate an API key.
- Google Account & Google Doc: Make sure you have a Google account and a Google Doc with comments that you want to process.
- Google Apps Script Access: This code runs in Google Apps Script, the scripting platform built into Google Workspace. It’s free and built right into Google Docs, Sheets, etc.
Step-by-Step Installation Guide
Step 1: Open the Google Apps Script Editor
- Open your target Google Doc.
- Go to Extensions > Apps Script. This will open the Apps Script editor in a new tab.
Step 2: Copy and Paste the Provided Code
- In the Apps Script editor, remove any placeholder code.
- Paste the entire code snippet you received into the editor.
// Configuration
const CONFIG = {
openAI: {
apiKey: PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY'),
apiUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-4o-mini',
temperature: 0.6,
maxTokensFAQ: 1000,
maxTokensSummary: 500
},
document: {
contextCharCount: 500,
maxSearchDepth: 10
},
styles: {
comment: {
backgroundColor: '#E6F3F7',
borderColor: '#2B5F75',
borderWidth: 1.5
},
summary: {
backgroundColor: '#E6E6FA',
borderColor: '#663399',
borderWidth: 1.5
},
text: {
fontFamily: 'Arial',
fontSize: 11
}
}
};
// Cache frequently used services
const Services = {
doc: DocumentApp.getActiveDocument(),
ui: DocumentApp.getUi(),
drive: DriveApp
};
// Utility Functions
const Utils = {
unescapeHtml: (text) => {
if (!text) return '';
const replacements = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
''': "'"
};
return text.replace(/&|<|>|"|'/g, match => replacements[match]);
},
formatTimestamp: (date) => {
return date.toISOString().replace(/[:.]/g, '-');
},
cleanQuotedText: (text) => {
return text
.replace(/\[.*?\]\s*/, '') // Remove bracketed content
.replace(/^[•\-]\s*/, '') // Remove bullet points
.replace(/\d+\+/g, '\\d+') // Handle numbers with plus signs
.replace(/\d+/g, '') // Remove standalone numbers
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
},
getContextAroundQuote: (body, quotedText) => {
if (!quotedText) return '';
const fullText = body.getText();
const quoteIndex = fullText.indexOf(quotedText);
if (quoteIndex === -1) return '';
const startIndex = Math.max(0, quoteIndex - CONFIG.document.contextCharCount);
const endIndex = Math.min(fullText.length, quoteIndex + quotedText.length + CONFIG.document.contextCharCount);
return fullText.substring(startIndex, endIndex);
},
findExactLocation: (body, quotedText, anchor) => {
Logger.log(`Finding location for quoted text: "${quotedText}"`);
Logger.log(`Anchor data: ${anchor}`);
if (!quotedText || quotedText.trim().length === 0) {
Logger.log('No quoted text provided.');
return null;
}
const cleanedText = Utils.cleanQuotedText(quotedText);
let location = Utils.searchForBestMatch(body, quotedText);
if (!location) {
Logger.log('No direct match with quoted text, trying cleaned text...');
location = Utils.searchForBestMatch(body, cleanedText);
}
// If still no location, try longest substring match
if (!location) {
Logger.log('No matches with full text. Trying longest substring match...');
location = Utils.findBestLongestSubstringMatch(body, quotedText);
}
if (!location) Logger.log('Could not find a suitable location match.');
return location;
},
// Search for best match by trying exact text first
searchForBestMatch: (body, searchText) => {
if (!searchText || searchText.length === 0) return null;
let currentSearch = body.findText(searchText);
if (!currentSearch) return null;
const matches = [];
let searchIndex = 0;
const MAX_MATCHES = 50;
while (currentSearch && searchIndex < MAX_MATCHES) {
try {
const element = currentSearch.getElement();
const startOffset = currentSearch.getStartOffset();
matches.push({
element: element,
startOffset: startOffset,
matchedText: searchText,
score: searchText.length // Score by length of matched text
});
} catch (e) {
Logger.log(`Error processing search result: ${e}`);
}
searchIndex++;
currentSearch = body.findText(searchText, currentSearch);
}
if (matches.length === 0) return null;
// If multiple matches, pick the one with the largest score (length)
// or if equal length, just pick the first.
matches.sort((a, b) => b.score - a.score);
return matches[0];
},
// Attempt to find the longest substring of quotedText present in the doc.
// We'll try removing words from start or end until we find a match.
findBestLongestSubstringMatch: (body, quotedText) => {
const words = quotedText.trim().split(/\s+/);
let bestMatch = null;
// Try substrings from longest (full text) to shortest
for (let length = words.length; length > 0; length--) {
// Try all substrings of this length
for (let start = 0; start <= words.length - length; start++) {
const substring = words.slice(start, start + length).join(' ');
let match = Utils.searchForBestMatch(body, substring);
if (match) {
// If we find a match, since we're going from longest to shortest,
// the first found will be the longest substring match.
return match;
}
}
}
return bestMatch;
}
};
// Document Service
const DocumentService = {
createDocumentCopy: () => {
const doc = Services.doc;
const originalFile = Services.drive.getFileById(doc.getId());
const timestamp = Utils.formatTimestamp(new Date());
const copyFile = originalFile.makeCopy(`Copy of ${doc.getName()} (${timestamp})`);
return DocumentApp.openById(copyFile.getId());
},
getComments: (documentId) => {
const data = [];
let pageToken;
do {
const commentList = Drive.Comments.list(documentId, {
fields: "*",
pageToken: pageToken
});
if (!commentList.comments) break;
commentList.comments.forEach(comment => {
if (DocumentService.shouldSkipComment(comment)) return;
data.push(DocumentService.formatCommentData(comment));
});
pageToken = commentList.nextPageToken;
} while (pageToken);
return data;
},
shouldSkipComment: (comment) => {
if (comment.resolved || comment.deleted || comment.status === 'RESOLVED' ||
(comment.content && comment.content.startsWith("MEMO"))) {
Logger.log(`Skipping comment - resolved: ${comment.resolved}, deleted: ${comment.deleted}, status: ${comment.status}`);
return true;
}
// Check if all replies are resolved or deleted
if (comment.replies && comment.replies.length > 0) {
const hasUnresolvedReplies = comment.replies.some(reply =>
!reply.deleted &&
!reply.resolved &&
reply.status !== 'RESOLVED' &&
!(reply.content && reply.content.startsWith("MEMO"))
);
if (!hasUnresolvedReplies) {
Logger.log('Skipping comment - all replies are resolved or deleted');
return true;
}
}
return false;
},
formatCommentData: (comment) => {
let commentContent = `Author: ${comment.author.displayName}\nTimestamp: ${new Date(comment.createdTime).toLocaleString()}\nComment: ${Utils.unescapeHtml(comment.content)}`;
if (comment.replies && comment.replies.length > 0) {
const unresolvedReplies = comment.replies.filter(reply =>
!reply.deleted &&
!reply.resolved &&
reply.status !== 'RESOLVED' &&
!(reply.content && reply.content.startsWith("MEMO"))
);
if (unresolvedReplies.length > 0) {
commentContent += DocumentService.formatReplies(unresolvedReplies);
}
}
const quotedText = Utils.unescapeHtml(comment.quotedFileContent && comment.quotedFileContent.value ? comment.quotedFileContent.value : '');
return {
author: comment.author.displayName,
timestamp: comment.createdTime,
quotedText: quotedText,
content: commentContent,
anchor: comment.anchor
};
},
formatReplies: (replies) => {
return replies.reduce((content, reply) => {
if (reply.deleted || (reply.content && reply.content.startsWith("MEMO"))) return content;
return content + `\n\nReply from ${reply.author.displayName} (${new Date(reply.createdTime).toLocaleString()}):\n${Utils.unescapeHtml(reply.content)}`;
}, '');
}
};
// Formatting Service
const FormattingService = {
styleTable: (table, style) => {
table.setBackgroundColor(style.backgroundColor)
.setBorderColor(style.borderColor)
.setBorderWidth(style.borderWidth);
return table;
},
styleCell: (cell, backgroundColor) => {
cell.setPaddingTop(10)
.setPaddingBottom(10)
.setPaddingLeft(10)
.setPaddingRight(10)
.setBackgroundColor(backgroundColor);
return cell;
},
styleText: (text, style) => {
text.setFontFamily(style.fontFamily)
.setFontSize(style.fontSize);
return text;
},
insertCommentTable: (body, insertionIndex, item) => {
const displayQuotedText = item.quotedText ? `Quoted Text:\n${item.quotedText}\n\n` : '';
const table = body.insertTable(insertionIndex + 1, [
[`${displayQuotedText}${item.content}`]
]);
FormattingService.styleTable(table, CONFIG.styles.comment);
const cell = FormattingService.styleCell(table.getCell(0, 0), CONFIG.styles.comment.backgroundColor);
const text = FormattingService.styleText(cell.editAsText(), CONFIG.styles.text);
if (item.quotedText) {
const labelLength = "Quoted Text:\n".length;
text.setBold(0, labelLength - 1, true);
text.setBold(labelLength, labelLength + item.quotedText.length - 1, true);
}
return table;
},
insertSummaryTable: (body, insertionIndex, summary) => {
const table = body.insertTable(insertionIndex + 2, [
[`GenAI Rewrite:\n${summary}`]
]);
FormattingService.styleTable(table, CONFIG.styles.summary);
const cell = FormattingService.styleCell(table.getCell(0, 0), CONFIG.styles.summary.backgroundColor);
const text = FormattingService.styleText(cell.editAsText(), CONFIG.styles.text);
text.setBold(0, 13, true);
FormattingService.formatBulletPoints(cell, summary);
return table;
},
formatBulletPoints: (cell, content) => {
const lines = content.split('\n');
lines.forEach(line => {
if (line.trim().startsWith('•')) {
const bulletPara = cell.appendListItem(line.trim().substring(2));
bulletPara.setGlyphType(DocumentApp.GlyphType.BULLET);
}
});
}
};
// OpenAI Service
const OpenAIService = {
callAPI: (messages, maxTokens) => {
const options = {
'method': 'post',
'headers': {
'Authorization': `Bearer ${CONFIG.openAI.apiKey}`,
'Content-Type': 'application/json'
},
'payload': JSON.stringify({
'model': CONFIG.openAI.model,
'messages': messages,
'temperature': CONFIG.openAI.temperature,
'max_tokens': maxTokens
})
};
try {
const response = UrlFetchApp.fetch(CONFIG.openAI.apiUrl, options);
const json = JSON.parse(response.getContentText());
return json.choices[0].message.content.trim();
} catch (error) {
Logger.log(`OpenAI API Error: ${error}`);
return null;
}
},
generateSummary: (commentThread, quotedText, context) => {
if (!quotedText) {
return null;
}
const messages = [{
'role': 'system',
'content': 'You are a helpful assistant. Return plain text that will be formatted in Google Docs. Do not use any special formatting characters.'
}, {
'role': 'user',
'content': `Please rewrite the content to include the recommendations made in the comments. Ensure the new content is concise, polished and professional. Only rewrite the section specific to the comment feedback. Do not include who made the comment, you are rewriting only to incorporate the new ideas.
Document Context: ${context}
Quoted Text: "${quotedText}"
Comment Thread: ${commentThread}`
}];
return OpenAIService.callAPI(messages, CONFIG.openAI.maxTokensSummary);
},
generateFAQSection: (fullText) => {
const messages = [{
'role': 'system',
'content': 'You are a helpful assistant that creates FAQs. Generate questions and answers in plain text format.'
}, {
'role': 'user',
'content': `Please analyze this document and create a comprehensive FAQ section. Generate questions that would be most helpful for understanding the key points, challenges, and solutions discussed. Include clear, concise answers.
Document Content: ${fullText}
Format your response with one question per line, followed by its answer. Each question should end with a question mark. Leave a blank line between each Q&A pair.`
}];
return OpenAIService.callAPI(messages, CONFIG.openAI.maxTokensFAQ);
}
};
// Menu Service
const MenuService = {
setup: () => {
Services.ui.createMenu('Comments')
.addItem('Insert Comments into Copy', 'insertCommentsIntoCopy')
.addItem('Log Comment Metadata', 'logCommentMetadata')
.addToUi();
},
logCommentMetadata: () => {
const documentId = Services.doc.getId();
let pageToken;
do {
const commentList = Drive.Comments.list(documentId, {
fields: "*",
pageToken: pageToken
});
if (!commentList.comments) {
Logger.log('No comments found');
break;
}
commentList.comments.forEach((comment, index) => {
Logger.log(`\n=== Comment ${index + 1} Metadata ===`);
Object.keys(comment).forEach(key => {
if (typeof comment[key] === 'object') {
Logger.log(`${key}:`);
Logger.log(JSON.stringify(comment[key], null, 2));
} else {
Logger.log(`${key}: ${comment[key]}`);
}
});
Logger.log('\nDeletion Status:');
Logger.log(`deleted: ${comment.deleted || false}`);
Logger.log(`modifiedTime: ${comment.modifiedTime || 'N/A'}`);
Logger.log(`status: ${comment.status || 'N/A'}`);
if (comment.quotedFileContent) {
Logger.log('\nQuoted Content Status:');
Logger.log(`value: ${comment.quotedFileContent.value}`);
Logger.log(`mimeType: ${comment.quotedFileContent.mimeType}`);
}
});
pageToken = commentList.nextPageToken;
} while (pageToken);
}
};
function parseAnchor(anchor) {
if (!anchor) return null;
if (anchor.trim().startsWith('{')) {
try {
const anchorObj = JSON.parse(anchor);
if (anchorObj && typeof anchorObj.startIndex === 'number') {
return anchorObj.startIndex;
}
} catch (e) {
Logger.log('Failed to parse anchor JSON: ' + e);
}
} else {
Logger.log('Anchor is not JSON, cannot parse: ' + anchor);
}
return null;
}
function findLocationByStartIndex(body, startIndex) {
let offset = 0;
for (let i = 0; i < body.getNumChildren(); i++) {
const child = body.getChild(i);
const type = child.getType();
if (type === DocumentApp.ElementType.PARAGRAPH ||
type === DocumentApp.ElementType.LIST_ITEM) {
const text = child.asText().getText();
const newOffset = offset + text.length + 1;
if (startIndex <= newOffset) {
return {
element: child,
startOffset: startIndex - offset,
endOffset: startIndex - offset
};
}
offset = newOffset;
} else if (type === DocumentApp.ElementType.TABLE) {
const table = child.asTable();
for (let r = 0; r < table.getNumRows(); r++) {
for (let c = 0; c < table.getRow(r).getNumCells(); c++) {
const cell = table.getRow(r).getCell(c);
const text = cell.editAsText().getText();
const newOffset = offset + text.length + 1;
if (startIndex <= newOffset) {
return {
element: cell,
startOffset: startIndex - offset,
endOffset: startIndex - offset
};
}
offset = newOffset;
}
}
}
}
return null;
}
function insertCommentsIntoCopy() {
Logger.log('Starting comment processing...');
const copyDoc = DocumentService.createDocumentCopy();
const body = copyDoc.getBody();
const data = DocumentService.getComments(Services.doc.getId());
sortCommentsByPosition(data, body);
data.forEach((item, index) => {
processComment(body, item, index, data.length);
});
appendFAQSection(body, data);
Logger.log('Finished processing all comments and generating FAQ.');
}
function processComment(body, item, index, total) {
Logger.log(`\n=== Processing comment ${index + 1}/${total} ===`);
Logger.log(`Author: ${item.author}`);
let location = null;
if (item.quotedText && item.quotedText.trim().length > 0) {
location = Utils.findExactLocation(body, item.quotedText, item.anchor);
}
if (!location && item.anchor) {
Logger.log('Attempting to locate comment via anchor data');
const startIndex = parseAnchor(item.anchor);
if (startIndex !== null) {
location = findLocationByStartIndex(body, startIndex);
}
}
let insertionIndex = -1;
if (location) {
insertionIndex = findCommentInsertionPoint(body, location.element);
}
if (insertionIndex === -1) {
Logger.log('No precise location found. Appending comment at the end.');
insertionIndex = body.getNumChildren() - 1;
}
insertCommentWithSummary(body, insertionIndex, item);
}
function insertCommentWithSummary(body, insertionIndex, item) {
FormattingService.insertCommentTable(body, insertionIndex, item);
let summary = null;
if (item.quotedText && item.quotedText.trim().length > 0) {
const context = Utils.getContextAroundQuote(body, item.quotedText);
summary = OpenAIService.generateSummary(item.content, item.quotedText, context);
}
if (summary) {
FormattingService.insertSummaryTable(body, insertionIndex, summary);
body.insertParagraph(insertionIndex + 3, '').setSpacingAfter(12);
} else {
body.insertParagraph(insertionIndex + 2, '').setSpacingAfter(12);
}
}
function findCommentInsertionPoint(body, element) {
Logger.log('Finding insertion point for found text');
try {
let container = null;
let searchDepth = 0;
let current = element;
while (current && !container && searchDepth < CONFIG.document.maxSearchDepth) {
searchDepth++;
Logger.log(`Searching for container - Depth ${searchDepth}, Element type: ${current.getType()}`);
if (current.getType() === DocumentApp.ElementType.PARAGRAPH ||
current.getType() === DocumentApp.ElementType.LIST_ITEM ||
current.getType() === DocumentApp.ElementType.TABLE_CELL) {
container = current;
Logger.log('Found container element');
} else {
current = current.getParent();
}
}
if (!container) {
Logger.log('Could not find containing element');
return -1;
}
let table = null;
current = container;
while (current && !table && searchDepth < CONFIG.document.maxSearchDepth) {
searchDepth++;
if (current.getType() === DocumentApp.ElementType.TABLE) {
table = current;
Logger.log('Found containing table');
break;
}
current = current.getParent();
}
if (table) {
const tableIndex = body.getChildIndex(table);
Logger.log(`Found table at index: ${tableIndex}`);
return tableIndex;
}
const elementIndex = body.getChildIndex(container);
Logger.log(`Using container element index: ${elementIndex}`);
return elementIndex;
} catch (error) {
Logger.log(`Error finding insertion point: ${error}`);
return -1;
}
}
function sortCommentsByPosition(data, body) {
data.sort((a, b) => {
if (!a.quotedText) return 1;
if (!b.quotedText) return -1;
const searchA = body.findText(a.quotedText);
const searchB = body.findText(b.quotedText);
const getSearchResult = (item, search) => {
if (search) return search;
const cleanText = item.quotedText
.replace(/\[.*?\]\s*/, '')
.replace(/^[•\-]\s*/, '')
.trim();
return body.findText(cleanText);
};
const resultA = getSearchResult(a, searchA);
const resultB = getSearchResult(b, searchB);
if (!resultA && !resultB) {
return new Date(a.timestamp) - new Date(b.timestamp);
}
if (!resultA) return 1;
if (!resultB) return -1;
const posA = resultA.getStartOffset();
const posB = resultB.getStartOffset();
if (posA === posB) {
return new Date(a.timestamp) - new Date(b.timestamp);
}
return posA - posB;
});
}
function appendFAQSection(body, data) {
Logger.log('Starting FAQ section generation...');
const fullText = body.getText();
const faqContent = OpenAIService.generateFAQSection(fullText);
if (!faqContent) {
Logger.log('No FAQ content generated');
return;
}
body.appendParagraph('Frequently Asked Questions')
.setHeading(DocumentApp.ParagraphHeading.HEADING1)
.setSpacingAfter(12);
const faqTable = body.appendTable([
[faqContent]
]);
faqTable.setBackgroundColor(CONFIG.styles.summary.backgroundColor)
.setBorderColor(CONFIG.styles.summary.borderColor)
.setBorderWidth(1.5);
const cell = faqTable.getCell(0, 0);
cell.setPaddingTop(10)
.setPaddingBottom(10)
.setPaddingLeft(10)
.setPaddingRight(10)
.setBackgroundColor(CONFIG.styles.summary.backgroundColor);
const text = cell.editAsText();
text.setFontFamily(CONFIG.styles.text.fontFamily)
.setFontSize(CONFIG.styles.text.fontSize);
const lines = faqContent.split('\n');
let currentPosition = 0;
lines.forEach(line => {
if (line.trim().endsWith('?')) {
cell.editAsText().setBold(currentPosition, currentPosition + line.length - 1, true);
}
currentPosition += line.length + 1;
});
Logger.log('FAQ section completed');
}
function onOpen() {
MenuService.setup();
}
function logCommentMetadata() {
MenuService.logCommentMetadata();
}
Step 3: Enable the Google Drive Advanced Service
- In the Apps Script editor, click on Services (the plus icon in the left toolbar) or Extensions > Advanced Google services depending on your editor interface.
- Scroll down to find Drive API.
- Toggle the Drive API on, then click OK.This step is critical because the script uses Drive.Comments.list() from the Drive advanced service to fetch comments.
Step 4: Set Your OpenAI API Key
- Click on Project Settings (gear icon in the left sidebar of the Apps Script editor).
- Under Script properties, select Open script properties.
- Click Add a property.
- Set Name to OPENAI_API_KEY and Value to your OpenAI API key.
- Click Save.
Step 5: Save Your Script
Click File > Save or the floppy disk icon to save the code.
Step 6: Authorize the Script
- Click the Run button (the triangle “play” icon).
- When prompted, review permissions and allow the script to access your Google account and documents.
Step 7: Refresh Your Google Doc
Go back to your original Google Doc and refresh the page. You should now see a “Comments” menu added to the top of your Doc interface.
Step 8: Run the Script from the Document
- Click Comments > Insert Comments into Copy.This creates a new copy of your document, inserts comments, and attempts to generate rewritten sections using OpenAI.
- After it finishes, check your Google Drive for a new document named “Copy of [Your Doc Name] (timestamp)”.
- Open that copy to see the comments, summaries, and a generated FAQ section at the end.
Step 9: (Optional) View Comment Metadata
If you want to log comment metadata:
- Click Comments > Log Comment Metadata.
- View the logs in the Apps Script editor (View > Logs).