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

297 lines
8.2 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 } 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): 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<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
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<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);