/** * 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 } 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): string { if (element.paragraph) { return paragraphToMarkdown(element.paragraph); } if (element.table) { return tableToMarkdown(element.table); } if (element.sectionBreak) { return '\n---\n'; } return ''; } /** Convert a Google Docs paragraph to markdown. */ function paragraphToMarkdown(paragraph: 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) { // Inline images — reference only, actual URL requires separate lookup 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 const body = doc.body?.content || []; const mdParts: string[] = []; for (const element of body) { const md = structuralElementToMarkdown(element); if (md) mdParts.push(md); } const markdown = mdParts.join('\n\n'); const tiptapJson = markdownToTiptap(markdown); const contentPlain = extractPlainTextFromTiptap(tiptapJson); notes.push({ title, content: tiptapJson, contentPlain, markdown, tags: [], sourceRef: { source: 'google-docs', externalId: docId, lastSyncedAt: Date.now(), contentHash: String(body.length), }, }); } 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);