Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:35:59 +08:00
commit 90883a4d25
287 changed files with 75058 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env node
/**
* Create Google Doc with Markdown Formatting
*
* Usage: bun create_doc.js <account> --title <title> [options]
*
* Options:
* --title Document title (required)
* --content Markdown content
* --folder Subfolder in Geoffrey (Research, Notes, Reports, Travel)
* --raw Don't apply markdown formatting (plain text)
*
* Examples:
* bun create_doc.js hrg --title "Meeting Notes" --folder Notes
* bun create_doc.js psd --title "Report" --content "# Header\n\nContent" --folder Research
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
const { ensureGeoffreyFolders } = require(path.join(__dirname, '..', 'utils', 'folder_manager'));
const { insertFormattedContent } = require(path.join(__dirname, '..', 'utils', 'markdown_to_docs'));
async function createDoc(account, options) {
const auth = await getAuthClient(account);
const docs = google.docs({ version: 'v1', auth });
const drive = google.drive({ version: 'v3', auth });
// Get or create Geoffrey folder structure
let folderId = null;
if (options.folder) {
const folderResult = await ensureGeoffreyFolders(account, options.folder);
folderId = folderResult.targetFolder;
}
// Create the document
const createResponse = await docs.documents.create({
requestBody: {
title: options.title,
},
});
const documentId = createResponse.data.documentId;
// Move to Geoffrey folder if specified
if (folderId) {
// Get current parents
const file = await drive.files.get({
fileId: documentId,
fields: 'parents',
});
// Move to Geoffrey folder
await drive.files.update({
fileId: documentId,
addParents: folderId,
removeParents: file.data.parents.join(','),
fields: 'id, parents',
});
}
// Add content if provided
let formattingInfo = null;
if (options.content) {
if (options.raw) {
// Plain text insertion
await docs.documents.batchUpdate({
documentId,
requestBody: {
requests: [{
insertText: {
location: { index: 1 },
text: options.content,
},
}],
},
});
} else {
// Markdown formatted insertion
formattingInfo = await insertFormattedContent(docs, documentId, options.content);
}
}
return {
success: true,
account,
document: {
id: documentId,
title: createResponse.data.title,
url: `https://docs.google.com/document/d/${documentId}/edit`,
folder: options.folder || 'root',
},
formatting: formattingInfo,
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// CLI interface
async function main() {
const args = process.argv.slice(2);
const account = args[0];
if (!account) {
console.error(JSON.stringify({
error: 'Missing account',
usage: 'bun create_doc.js <account> --title <title> [--content <markdown>] [--folder <subfolder>]'
}));
process.exit(1);
}
// Parse options
const options = {};
for (let i = 1; i < args.length; i++) {
switch (args[i]) {
case '--title':
options.title = args[++i];
break;
case '--content':
options.content = args[++i];
break;
case '--folder':
options.folder = args[++i];
break;
case '--raw':
options.raw = true;
break;
}
}
if (!options.title) {
console.error(JSON.stringify({
error: 'Missing --title option',
usage: 'bun create_doc.js <account> --title <title> [--content <markdown>] [--folder <subfolder>]'
}));
process.exit(1);
}
try {
const result = await createDoc(account, options);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
}));
process.exit(1);
}
}
main();
module.exports = { createDoc };

View File

@@ -0,0 +1,282 @@
#!/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 };

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* Read Google Doc Content
*
* Usage: node read_doc.js <account> <document-id>
*
* Examples:
* node read_doc.js psd 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms
*/
const { google } = require('googleapis');
const path = require('path');
const { getAuthClient } = require(path.join(__dirname, '..', 'auth', 'token_manager'));
async function readDoc(account, documentId) {
const auth = await getAuthClient(account);
const docs = google.docs({ version: 'v1', auth });
const response = await docs.documents.get({
documentId,
});
const doc = response.data;
// Extract text content from document body
let textContent = '';
if (doc.body && doc.body.content) {
for (const element of doc.body.content) {
if (element.paragraph && element.paragraph.elements) {
for (const textElement of element.paragraph.elements) {
if (textElement.textRun && textElement.textRun.content) {
textContent += textElement.textRun.content;
}
}
}
if (element.table) {
textContent += '[TABLE]\n';
}
}
}
return {
success: true,
account,
document: {
id: doc.documentId,
title: doc.title,
textContent: textContent.trim(),
revisionId: doc.revisionId,
},
metadata: {
timestamp: new Date().toISOString(),
}
};
}
// 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: 'node read_doc.js <account> <document-id>'
}));
process.exit(1);
}
try {
const result = await readDoc(account, documentId);
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({
error: error.message,
account,
documentId,
}));
process.exit(1);
}
}
main();
module.exports = { readDoc };