Files
2025-11-30 08:35:59 +08:00

283 lines
7.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Edit Google Doc with Markdown Formatting
*
* Usage: bun edit_doc.js <account> <document-id> --append <markdown>
* bun edit_doc.js <account> <document-id> --replace <old> --with <new>
*
* Options:
* --raw Don't apply markdown formatting (plain text)
*
* Examples:
* bun edit_doc.js psd DOC_ID --append "## New Section\n\nContent here"
* bun edit_doc.js psd DOC_ID --append "Plain text" --raw
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
const { parseMarkdown } = require(path.join(__dirname, '..', 'utils', 'markdown_to_docs'));
/**
* Parse markdown with custom start index (for appending)
*/
function parseMarkdownAtIndex(markdown, startIndex) {
const requests = [];
let plainText = '';
let currentIndex = startIndex;
const lines = markdown.split('\n');
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Horizontal rule
if (line.match(/^---+$/)) {
const ruleLine = '─'.repeat(50) + '\n';
plainText += ruleLine;
currentIndex += ruleLine.length;
continue;
}
// Headers
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const headerContent = headerMatch[2];
const { plainText: processedText, requests: inlineRequests } =
processInlineFormattingAtIndex(headerContent, currentIndex);
const fullLine = processedText + '\n';
const headingStyle = level === 1 ? 'HEADING_1' :
level === 2 ? 'HEADING_2' :
level === 3 ? 'HEADING_3' : 'HEADING_4';
requests.push({
updateParagraphStyle: {
range: { startIndex: currentIndex, endIndex: currentIndex + fullLine.length },
paragraphStyle: { namedStyleType: headingStyle },
fields: 'namedStyleType',
},
});
requests.push(...inlineRequests);
plainText += fullLine;
currentIndex += fullLine.length;
continue;
}
// Bullet lists
const bulletMatch = line.match(/^[-*]\s+(.+)$/);
if (bulletMatch) {
const { plainText: processedText, requests: inlineRequests } =
processInlineFormattingAtIndex(bulletMatch[1], currentIndex + 2);
const fullLine = '• ' + processedText + '\n';
plainText += fullLine;
requests.push(...inlineRequests);
currentIndex += fullLine.length;
continue;
}
// Numbered lists
const numberedMatch = line.match(/^(\d+)\.\s+(.+)$/);
if (numberedMatch) {
const prefix = numberedMatch[1] + '. ';
const { plainText: processedText, requests: inlineRequests } =
processInlineFormattingAtIndex(numberedMatch[2], currentIndex + prefix.length);
const fullLine = prefix + processedText + '\n';
plainText += fullLine;
requests.push(...inlineRequests);
currentIndex += fullLine.length;
continue;
}
// Regular line
const { plainText: processedText, requests: inlineRequests } =
processInlineFormattingAtIndex(line, currentIndex);
const fullLine = processedText + '\n';
plainText += fullLine;
requests.push(...inlineRequests);
currentIndex += fullLine.length;
}
return { text: plainText, requests };
}
function processInlineFormattingAtIndex(text, startIndex) {
const requests = [];
let plainText = '';
let charIndex = startIndex;
let remaining = text;
while (remaining.length > 0) {
const boldMatch = remaining.match(/^\*\*(.+?)\*\*/);
if (boldMatch) {
plainText += boldMatch[1];
requests.push({
updateTextStyle: {
range: { startIndex: charIndex, endIndex: charIndex + boldMatch[1].length },
textStyle: { bold: true },
fields: 'bold',
},
});
charIndex += boldMatch[1].length;
remaining = remaining.substring(boldMatch[0].length);
continue;
}
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
plainText += linkMatch[1];
requests.push({
updateTextStyle: {
range: { startIndex: charIndex, endIndex: charIndex + linkMatch[1].length },
textStyle: { link: { url: linkMatch[2] } },
fields: 'link',
},
});
charIndex += linkMatch[1].length;
remaining = remaining.substring(linkMatch[0].length);
continue;
}
plainText += remaining[0];
charIndex++;
remaining = remaining.substring(1);
}
return { plainText, requests };
}
async function editDoc(account, documentId, options) {
const auth = await getAuthClient(account);
const docs = google.docs({ version: 'v1', auth });
if (options.append) {
// Get document to find end index
const doc = await docs.documents.get({ documentId });
const endIndex = doc.data.body.content[doc.data.body.content.length - 1].endIndex - 1;
if (options.raw) {
// Plain text append
await docs.documents.batchUpdate({
documentId,
requestBody: {
requests: [{
insertText: {
location: { index: endIndex },
text: '\n' + options.append,
},
}],
},
});
return {
success: true,
account,
documentId,
updates: 1,
metadata: { timestamp: new Date().toISOString() }
};
}
// Formatted append
const { text, requests } = parseMarkdownAtIndex(options.append, endIndex + 1);
// Insert text
await docs.documents.batchUpdate({
documentId,
requestBody: {
requests: [{
insertText: {
location: { index: endIndex },
text: '\n' + text,
},
}],
},
});
// Apply formatting
if (requests.length > 0) {
await docs.documents.batchUpdate({
documentId,
requestBody: { requests },
});
}
return {
success: true,
account,
documentId,
updates: 1 + requests.length,
formatting: { textLength: text.length, formattingRequests: requests.length },
metadata: { timestamp: new Date().toISOString() }
};
}
if (options.replace && options.with) {
await docs.documents.batchUpdate({
documentId,
requestBody: {
requests: [{
replaceAllText: {
containsText: { text: options.replace, matchCase: true },
replaceText: options.with,
},
}],
},
});
return {
success: true,
account,
documentId,
updates: 1,
metadata: { timestamp: new Date().toISOString() }
};
}
return { success: false, error: 'No edit operation specified' };
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
const documentId = args[1];
if (!account || !documentId) {
console.error(JSON.stringify({
error: 'Missing arguments',
usage: 'bun edit_doc.js <account> <document-id> --append <markdown>'
}));
process.exit(1);
}
const options = {};
for (let i = 2; i < args.length; i++) {
switch (args[i]) {
case '--append': options.append = args[++i]; break;
case '--replace': options.replace = args[++i]; break;
case '--with': options.with = args[++i]; break;
case '--raw': options.raw = true; break;
}
}
try {
const result = await editDoc(account, documentId, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
documentId,
}));
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { editDoc };