297 lines
8.2 KiB
TypeScript
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 += ``;
|
|
}
|
|
}
|
|
|
|
// 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);
|