/** * 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 { 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 { 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 { 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 { 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 { 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);