rspace-online/modules/rnotes/converters/google-docs.ts

330 lines
9.8 KiB
TypeScript

/**
* Google Docs ↔ rNotes converter.
*
* Import: Google Docs API structural JSON → markdown → TipTap JSON
* Export: TipTap JSON → Google Docs batch update requests
*/
import { markdownToTiptap, tiptapToMarkdown, extractPlainTextFromTiptap } from './markdown-tiptap';
import { registerConverter, hashContent } from './index';
import type { ConvertedNote, ImportInput, ImportResult, ExportOptions, ExportResult, NoteConverter } from './index';
import type { NoteItem } from '../schemas';
const DOCS_API_BASE = 'https://docs.googleapis.com/v1';
const DRIVE_API_BASE = 'https://www.googleapis.com/drive/v3';
/** Fetch from Google APIs with auth. */
async function googleFetch(url: string, token: string, opts: RequestInit = {}): Promise<any> {
const res = await fetch(url, {
...opts,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...opts.headers,
},
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Google API error ${res.status}: ${body}`);
}
return res.json();
}
/** Convert Google Docs structural elements to markdown. */
function structuralElementToMarkdown(element: any, inlineObjects?: Record<string, any>): string {
if (element.paragraph) {
return paragraphToMarkdown(element.paragraph, inlineObjects);
}
if (element.table) {
return tableToMarkdown(element.table);
}
if (element.sectionBreak) {
return '\n---\n';
}
return '';
}
/** Convert a Google Docs paragraph to markdown (with inline image resolution context). */
function paragraphToMarkdown(paragraph: any, inlineObjects?: Record<string, any>): string {
const style = paragraph.paragraphStyle?.namedStyleType || 'NORMAL_TEXT';
const elements = paragraph.elements || [];
let text = '';
for (const el of elements) {
if (el.textRun) {
text += textRunToMarkdown(el.textRun);
} else if (el.inlineObjectElement) {
const objectId = el.inlineObjectElement.inlineObjectId;
const obj = inlineObjects?.[objectId];
if (obj) {
const imageProps = obj.inlineObjectProperties?.embeddedObject?.imageProperties;
const contentUri = imageProps?.contentUri;
if (contentUri) {
text += `![image](${contentUri})`;
} else {
text += `![image](inline-object-${objectId})`;
}
} else {
text += `![image](inline-object)`;
}
}
}
// Remove trailing newline that Google Docs adds to every paragraph
text = text.replace(/\n$/, '');
// Apply heading styles
switch (style) {
case 'HEADING_1': return `# ${text}`;
case 'HEADING_2': return `## ${text}`;
case 'HEADING_3': return `### ${text}`;
case 'HEADING_4': return `#### ${text}`;
case 'HEADING_5': return `##### ${text}`;
case 'HEADING_6': return `###### ${text}`;
default: return text;
}
}
/** Convert a Google Docs TextRun to markdown with formatting. */
function textRunToMarkdown(textRun: any): string {
let text = textRun.content || '';
const style = textRun.textStyle || {};
// Don't apply formatting to whitespace-only text
if (!text.trim()) return text;
if (style.bold) text = `**${text.trim()}** `;
if (style.italic) text = `*${text.trim()}* `;
if (style.strikethrough) text = `~~${text.trim()}~~ `;
if (style.link?.url) text = `[${text.trim()}](${style.link.url})`;
return text;
}
/** Convert a Google Docs table to markdown. */
function tableToMarkdown(table: any): string {
const rows = table.tableRows || [];
if (rows.length === 0) return '';
const mdRows: string[] = [];
for (let r = 0; r < rows.length; r++) {
const cells = rows[r].tableCells || [];
const cellTexts = cells.map((cell: any) => {
const content = (cell.content || [])
.map((el: any) => structuralElementToMarkdown(el))
.join('')
.trim();
return content || ' ';
});
mdRows.push(`| ${cellTexts.join(' | ')} |`);
// Separator after header
if (r === 0) {
mdRows.push(`| ${cellTexts.map(() => '---').join(' | ')} |`);
}
}
return mdRows.join('\n');
}
/** Convert TipTap markdown to Google Docs batchUpdate requests. */
function markdownToGoogleDocsRequests(md: string): any[] {
const requests: any[] = [];
const lines = md.split('\n');
let index = 1; // Google Docs indexes start at 1
for (const line of lines) {
if (!line && lines.indexOf(line) < lines.length - 1) {
// Empty line → insert newline
requests.push({
insertText: { location: { index }, text: '\n' },
});
index += 1;
continue;
}
// Headings
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
if (headingMatch) {
const level = headingMatch[1].length;
const text = headingMatch[2] + '\n';
requests.push({
insertText: { location: { index }, text },
});
requests.push({
updateParagraphStyle: {
range: { startIndex: index, endIndex: index + text.length },
paragraphStyle: { namedStyleType: `HEADING_${level}` },
fields: 'namedStyleType',
},
});
index += text.length;
continue;
}
// Regular text
const text = line.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '') + '\n';
requests.push({
insertText: { location: { index }, text },
});
// Apply bullet/list styles
if (line.match(/^[-*]\s+/)) {
requests.push({
createParagraphBullets: {
range: { startIndex: index, endIndex: index + text.length },
bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE',
},
});
} else if (line.match(/^\d+\.\s+/)) {
requests.push({
createParagraphBullets: {
range: { startIndex: index, endIndex: index + text.length },
bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN',
},
});
}
index += text.length;
}
return requests;
}
const googleDocsConverter: NoteConverter = {
id: 'google-docs',
name: 'Google Docs',
requiresAuth: true,
async import(input: ImportInput): Promise<ImportResult> {
const token = input.accessToken;
if (!token) throw new Error('Google Docs import requires an access token. Connect your Google account first.');
if (!input.pageIds || input.pageIds.length === 0) {
throw new Error('No Google Docs selected for import');
}
const notes: ConvertedNote[] = [];
const warnings: string[] = [];
for (const docId of input.pageIds) {
try {
// Fetch document
const doc = await googleFetch(`${DOCS_API_BASE}/documents/${docId}`, token);
const title = doc.title || 'Untitled';
// Convert structural elements to markdown, passing inlineObjects for image resolution
const body = doc.body?.content || [];
const inlineObjects = doc.inlineObjects || {};
const mdParts: string[] = [];
for (const element of body) {
const md = structuralElementToMarkdown(element, inlineObjects);
if (md) mdParts.push(md);
}
const markdown = mdParts.join('\n\n');
const tiptapJson = markdownToTiptap(markdown);
const contentPlain = extractPlainTextFromTiptap(tiptapJson);
// Download inline images as attachments
const attachments: { filename: string; data: Uint8Array; mimeType: string }[] = [];
for (const [objectId, obj] of Object.entries(inlineObjects) as [string, any][]) {
const imageProps = obj.inlineObjectProperties?.embeddedObject?.imageProperties;
const contentUri = imageProps?.contentUri;
if (contentUri) {
try {
const res = await fetch(contentUri, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (res.ok) {
const data = new Uint8Array(await res.arrayBuffer());
const ct = res.headers.get('content-type') || 'image/png';
const ext = ct.includes('jpeg') || ct.includes('jpg') ? 'jpg' : ct.includes('gif') ? 'gif' : ct.includes('webp') ? 'webp' : 'png';
attachments.push({ filename: `gdocs-${objectId}.${ext}`, data, mimeType: ct });
}
} catch { /* skip failed image downloads */ }
}
}
notes.push({
title,
content: tiptapJson,
contentPlain,
markdown,
tags: [],
attachments: attachments.length > 0 ? attachments : undefined,
sourceRef: {
source: 'google-docs',
externalId: docId,
lastSyncedAt: Date.now(),
contentHash: hashContent(markdown),
},
});
} catch (err) {
warnings.push(`Failed to import doc ${docId}: ${(err as Error).message}`);
}
}
return { notes, notebookTitle: 'Google Docs Import', warnings };
},
async export(notes: NoteItem[], opts: ExportOptions): Promise<ExportResult> {
const token = opts.accessToken;
if (!token) throw new Error('Google Docs export requires an access token. Connect your Google account first.');
const warnings: string[] = [];
const results: any[] = [];
for (const note of notes) {
try {
// Create a new Google Doc
const doc = await googleFetch(`${DOCS_API_BASE}/documents`, token, {
method: 'POST',
body: JSON.stringify({ title: note.title }),
});
// Convert to markdown
let md: string;
if (note.contentFormat === 'tiptap-json' && note.content) {
md = tiptapToMarkdown(note.content);
} else {
md = note.content?.replace(/<[^>]*>/g, '').trim() || '';
}
// Build batch update requests
const requests = markdownToGoogleDocsRequests(md);
if (requests.length > 0) {
await googleFetch(`${DOCS_API_BASE}/documents/${doc.documentId}:batchUpdate`, token, {
method: 'POST',
body: JSON.stringify({ requests }),
});
}
// Move to folder if parentId specified
if (opts.parentId) {
await googleFetch(
`${DRIVE_API_BASE}/files/${doc.documentId}?addParents=${opts.parentId}`,
token,
{ method: 'PATCH', body: JSON.stringify({}) }
);
}
results.push({ noteId: note.id, googleDocId: doc.documentId });
} catch (err) {
warnings.push(`Failed to export "${note.title}": ${(err as Error).message}`);
}
}
const data = new TextEncoder().encode(JSON.stringify({ exported: results, warnings }));
return {
data,
filename: 'google-docs-export-results.json',
mimeType: 'application/json',
};
},
};
registerConverter(googleDocsConverter);