fixed Granola sync

This commit is contained in:
Arjun 2026-01-14 15:29:50 +05:30 committed by Ramnique Singh
parent c60d6d11ff
commit 8c4bb7a73a
2 changed files with 240 additions and 148 deletions

View file

@ -5,9 +5,7 @@ import { WorkDir } from '../../config/config.js';
import container from '../../di/container.js'; import container from '../../di/container.js';
import { IGranolaConfigRepo } from './repo.js'; import { IGranolaConfigRepo } from './repo.js';
import { import {
GetWorkspacesResponse, GetDocumentsResponse,
GetDocumentListsResponse,
GetDocumentsBatchResponse,
SyncState, SyncState,
Document, Document,
} from './types.js'; } from './types.js';
@ -19,7 +17,11 @@ const GRANOLA_API_BASE = 'https://api.granola.ai';
const GRANOLA_CONFIG_PATH = path.join(homedir(), 'Library', 'Application Support', 'Granola', 'supabase.json'); const GRANOLA_CONFIG_PATH = path.join(homedir(), 'Library', 'Application Support', 'Granola', 'supabase.json');
const SYNC_DIR = path.join(WorkDir, 'granola_notes'); const SYNC_DIR = path.join(WorkDir, 'granola_notes');
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json'); const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
const SYNC_INTERVAL_MS = 60 * 1000; // Check every minute const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
const API_DELAY_MS = 1000; // 1 second delay between API calls
const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit
const MAX_RETRIES = 3; // Maximum retries for rate-limited requests
const MAX_BATCH_SIZE = 10; // Process max 10 documents per folder per sync
// --- Token Extraction --- // --- Token Extraction ---
@ -63,6 +65,52 @@ function extractAccessToken(): string | null {
} }
} }
// --- Helper Functions ---
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function callWithRateLimit<T>(
operation: () => Promise<T>,
operationName: string
): Promise<T | null> {
let retries = 0;
let delay = RATE_LIMIT_RETRY_DELAY_MS;
while (retries < MAX_RETRIES) {
try {
const result = await operation();
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Check if it's a rate limit error (429 Too Many Requests)
if (errorMessage.includes('429') ||
errorMessage.includes('Too Many Requests') ||
errorMessage.includes('too many requests') ||
errorMessage.includes('rate limit')) {
retries++;
console.log(`[Granola] Rate limit hit for ${operationName}. Retry ${retries}/${MAX_RETRIES} in ${delay/1000}s...`);
if (retries >= MAX_RETRIES) {
console.error(`[Granola] Max retries reached for ${operationName}. Skipping.`);
return null;
}
await sleep(delay);
delay *= 2; // Exponential backoff
} else {
// Not a rate limit error, throw it
throw error;
}
}
}
return null;
}
// --- API Client --- // --- API Client ---
function getHeaders(accessToken: string): Record<string, string> { function getHeaders(accessToken: string): Record<string, string> {
@ -78,8 +126,8 @@ async function apiCall<T>(
endpoint: string, endpoint: string,
accessToken: string, accessToken: string,
body: Record<string, unknown> = {} body: Record<string, unknown> = {}
): Promise<T | null> { ): Promise<T> {
try { console.log(`[Granola] API call: ${endpoint}`);
const response = await fetch(`${GRANOLA_API_BASE}${endpoint}`, { const response = await fetch(`${GRANOLA_API_BASE}${endpoint}`, {
method: 'POST', method: 'POST',
headers: getHeaders(accessToken), headers: getHeaders(accessToken),
@ -87,54 +135,35 @@ async function apiCall<T>(
}); });
if (!response.ok) { if (!response.ok) {
console.error(`[Granola] API error ${response.status}: ${response.statusText}`); const errorText = await response.text().catch(() => 'no body');
return null; console.error(`[Granola] API error ${response.status}: ${response.statusText} - ${errorText.slice(0, 200)}`);
// Throw error with status code so rate limit handler can detect 429
throw new Error(`${response.status}: ${response.statusText}`);
} }
return await response.json() as T; const data = await response.json() as T;
} catch (error) { console.log(`[Granola] API success: ${endpoint}`);
console.error(`[Granola] API call failed for ${endpoint}:`, error); return data;
return null;
}
} }
async function getWorkspaces(accessToken: string) { async function getDocuments(accessToken: string, limit: number, offset: number) {
const response = await apiCall<unknown>('/v1/get-workspaces', accessToken); const response = await callWithRateLimit(
if (!response) return null; () => apiCall<unknown>('/v2/get-documents', accessToken, {
limit,
try { offset,
return GetWorkspacesResponse.parse(response);
} catch (error) {
console.error('[Granola] Failed to parse workspaces response:', error);
return null;
}
}
async function getDocumentLists(accessToken: string) {
const response = await apiCall<unknown>('/v2/get-document-lists', accessToken);
if (!response) return null;
try {
return GetDocumentListsResponse.parse(response);
} catch (error) {
console.error('[Granola] Failed to parse document lists response:', error);
return null;
}
}
async function getDocumentsBatch(accessToken: string, documentIds: string[]) {
if (documentIds.length === 0) return { docs: [] };
const response = await apiCall<unknown>('/v1/get-documents-batch', accessToken, {
document_ids: documentIds,
include_last_viewed_panel: true, include_last_viewed_panel: true,
}); }),
'get-documents'
);
if (!response) return null; if (!response) return null;
try { try {
return GetDocumentsBatchResponse.parse(response); const parsed = GetDocumentsResponse.parse(response);
console.log(`[Granola] Fetched ${parsed.docs.length} documents (offset: ${offset})`);
return parsed;
} catch (error) { } catch (error) {
console.error('[Granola] Failed to parse documents batch response:', error); console.error('[Granola] Failed to parse documents response:', error);
console.error('[Granola] Raw response:', JSON.stringify(response, null, 2).slice(0, 1000));
return null; return null;
} }
} }
@ -169,6 +198,77 @@ function ensureDir(dirPath: string): void {
} }
} }
interface ProseMirrorNode {
type: string;
attrs?: Record<string, unknown>;
content?: ProseMirrorNode[];
text?: string;
}
function convertProseMirrorToMarkdown(content: ProseMirrorNode | undefined): string {
if (!content || typeof content !== 'object' || !content.content) {
return '';
}
function processNode(node: ProseMirrorNode): string {
if (!node || typeof node !== 'object') {
return '';
}
const nodeType = node.type || '';
const children = node.content || [];
const text = node.text || '';
if (nodeType === 'heading') {
const level = (node.attrs?.level as number) || 1;
const headingText = children.map(processNode).join('');
return `${'#'.repeat(level)} ${headingText}\n\n`;
}
if (nodeType === 'paragraph') {
const paraText = children.map(processNode).join('');
return `${paraText}\n\n`;
}
if (nodeType === 'bulletList') {
const items: string[] = [];
for (const item of children) {
if (item.type === 'listItem') {
const itemContent = (item.content || []).map(processNode).join('').trim();
items.push(`- ${itemContent}`);
}
}
return items.join('\n') + '\n\n';
}
if (nodeType === 'orderedList') {
const items: string[] = [];
let num = 1;
for (const item of children) {
if (item.type === 'listItem') {
const itemContent = (item.content || []).map(processNode).join('').trim();
items.push(`${num}. ${itemContent}`);
num++;
}
}
return items.join('\n') + '\n\n';
}
if (nodeType === 'text') {
return text;
}
if (nodeType === 'hardBreak') {
return '\n';
}
// For other node types, recursively process children
return children.map(processNode).join('');
}
return processNode(content);
}
function documentToMarkdown(doc: Document): string { function documentToMarkdown(doc: Document): string {
const title = doc.title || 'Untitled'; const title = doc.title || 'Untitled';
const createdAt = doc.created_at; const createdAt = doc.created_at;
@ -181,8 +281,14 @@ function documentToMarkdown(doc: Document): string {
md += `updated_at: ${updatedAt}\n`; md += `updated_at: ${updatedAt}\n`;
md += `---\n\n`; md += `---\n\n`;
// Use notes_markdown if available, otherwise notes_plain // Try last_viewed_panel content first (ProseMirror format)
if (doc.notes_markdown) { const lastViewedContent = doc.last_viewed_panel?.content;
if (lastViewedContent && typeof lastViewedContent === 'object' && lastViewedContent.type === 'doc') {
md += convertProseMirrorToMarkdown(lastViewedContent as ProseMirrorNode);
} else if (doc.notes && typeof doc.notes === 'object' && doc.notes.type === 'doc') {
// Fall back to notes field (also ProseMirror format)
md += convertProseMirrorToMarkdown(doc.notes as ProseMirrorNode);
} else if (doc.notes_markdown) {
md += doc.notes_markdown; md += doc.notes_markdown;
} else if (doc.notes_plain) { } else if (doc.notes_plain) {
md += doc.notes_plain; md += doc.notes_plain;
@ -217,56 +323,28 @@ async function syncNotes(): Promise<void> {
// Load state // Load state
const state = loadState(); const state = loadState();
// Get workspaces
const workspacesResponse = await getWorkspaces(accessToken);
if (!workspacesResponse) {
console.log('[Granola] Failed to fetch workspaces');
return;
}
console.log(`[Granola] Found ${workspacesResponse.workspaces.length} workspaces`);
// Build workspace lookup
const workspaceMap = new Map<string, { slug: string; displayName: string }>();
for (const ws of workspacesResponse.workspaces) {
workspaceMap.set(ws.workspace.workspace_id, {
slug: ws.workspace.slug,
displayName: ws.workspace.display_name,
});
}
// Get document lists (folders)
const listsResponse = await getDocumentLists(accessToken);
if (!listsResponse) {
console.log('[Granola] Failed to fetch document lists');
return;
}
console.log(`[Granola] Found ${listsResponse.lists.length} folders`);
let newCount = 0; let newCount = 0;
let updatedCount = 0; let updatedCount = 0;
let offset = 0;
let hasMore = true;
// Process each folder // Fetch documents with pagination
for (const list of listsResponse.lists) { while (hasMore) {
const folderName = cleanFilename(list.title); // Delay before API call (except first)
const folderPath = path.join(SYNC_DIR, folderName); if (offset > 0) {
await sleep(API_DELAY_MS);
// Get document IDs from the list
const docIds = list.documents.map(d => d.id);
if (docIds.length === 0) {
console.log(`[Granola] Folder "${list.title}" is empty, skipping`);
continue;
} }
console.log(`[Granola] Processing folder "${list.title}" with ${docIds.length} documents`); const docsResponse = await getDocuments(accessToken, MAX_BATCH_SIZE, offset);
// Fetch full documents
const docsResponse = await getDocumentsBatch(accessToken, docIds);
if (!docsResponse) { if (!docsResponse) {
console.log(`[Granola] Failed to fetch documents for folder "${list.title}"`); console.log('[Granola] Failed to fetch documents');
continue; break;
}
if (docsResponse.docs.length === 0) {
console.log('[Granola] No more documents to fetch');
hasMore = false;
break;
} }
// Process each document // Process each document
@ -281,14 +359,11 @@ async function syncNotes(): Promise<void> {
continue; continue;
} }
// Ensure folder exists
ensureDir(folderPath);
// Convert to markdown and save // Convert to markdown and save
const markdown = documentToMarkdown(doc); const markdown = documentToMarkdown(doc);
const docTitle = doc.title || 'Untitled'; const docTitle = doc.title || 'Untitled';
const filename = `${doc.id}_${cleanFilename(docTitle)}.md`; const filename = `${doc.id}_${cleanFilename(docTitle)}.md`;
const filePath = path.join(folderPath, filename); const filePath = path.join(SYNC_DIR, filename);
fs.writeFileSync(filePath, markdown); fs.writeFileSync(filePath, markdown);
@ -303,6 +378,14 @@ async function syncNotes(): Promise<void> {
// Update state // Update state
state.syncedDocs[doc.id] = docUpdatedAt; state.syncedDocs[doc.id] = docUpdatedAt;
} }
// Move to next page
offset += docsResponse.docs.length;
// Stop if we got fewer docs than requested (last page)
if (docsResponse.docs.length < MAX_BATCH_SIZE) {
hasMore = false;
}
} }
// Save state // Save state
@ -321,7 +404,7 @@ async function syncNotes(): Promise<void> {
export async function init(): Promise<void> { export async function init(): Promise<void> {
console.log('[Granola] Starting Granola Sync...'); console.log('[Granola] Starting Granola Sync...');
console.log(`[Granola] Will check every ${SYNC_INTERVAL_MS / 1000} seconds.`); console.log(`[Granola] Will check every ${SYNC_INTERVAL_MS / 60000} minutes.`);
console.log(`[Granola] Notes will be saved to: ${SYNC_DIR}`); console.log(`[Granola] Notes will be saved to: ${SYNC_DIR}`);
while (true) { while (true) {
@ -332,7 +415,7 @@ export async function init(): Promise<void> {
} }
// Sleep before next check // Sleep before next check
console.log(`[Granola] Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`); console.log(`[Granola] Sleeping for ${SYNC_INTERVAL_MS / 60000} minutes...`);
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS));
} }
} }

View file

@ -9,32 +9,36 @@ export type GranolaConfig = z.infer<typeof GranolaConfig>;
// --- API Schemas --- // --- API Schemas ---
// ProseMirror node (recursive structure)
export const ProseMirrorNode: z.ZodType<{
type: string;
attrs?: Record<string, unknown>;
content?: unknown[];
text?: string;
}> = z.object({
type: z.string(),
attrs: z.record(z.string(), z.unknown()).optional(),
content: z.array(z.lazy(() => ProseMirrorNode)).optional(),
text: z.string().optional(),
}).passthrough();
export const Document = z.object({ export const Document = z.object({
id: z.string(), id: z.string(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string().nullable(), updated_at: z.string().nullable().optional(),
deleted_at: z.string().nullable(), deleted_at: z.string().nullable().optional(),
notes: z.object({ title: z.string().nullable().optional(),
type: z.string(), type: z.string().nullable().optional(),
content: z.array(z.object({ user_id: z.string().optional(),
type: z.string(), workspace_id: z.string().nullable().optional(),
attrs: z.object({ public: z.boolean().optional(),
id: z.string(), notes: ProseMirrorNode.optional().nullable(),
}).optional(), notes_plain: z.string().nullable().optional(),
content: z.array(z.object({ notes_markdown: z.string().nullable().optional(),
type: z.string(), last_viewed_panel: z.object({
text: z.string().optional(), content: z.union([ProseMirrorNode, z.string()]).optional().nullable(),
})).optional(), }).passthrough().optional().nullable(),
})), }).passthrough(); // Allow additional fields
}).optional(),
title: z.string().nullable(),
type: z.string(),
user_id: z.string(),
notes_plain: z.string().optional(),
notes_markdown: z.string().optional(),
workspace_id: z.string().nullable(),
public: z.boolean(),
});
export type Document = z.infer<typeof Document>; export type Document = z.infer<typeof Document>;
export const GetWorkspacesResponse = z.object({ export const GetWorkspacesResponse = z.object({
@ -76,12 +80,17 @@ export const GetDocumentTranscriptResponse = z.array(z.object({
})); }));
export type GetDocumentTranscriptResponse = z.infer<typeof GetDocumentTranscriptResponse>; export type GetDocumentTranscriptResponse = z.infer<typeof GetDocumentTranscriptResponse>;
// Document reference in a list (may be partial, we only need id)
export const DocumentRef = z.object({
id: z.string(),
}).passthrough(); // Allow additional fields
export const DocumentListItem = z.object({ export const DocumentListItem = z.object({
id: z.string(), id: z.string(),
title: z.string(), title: z.string(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
documents: z.array(Document), documents: z.array(DocumentRef),
}); });
export type DocumentListItem = z.infer<typeof DocumentListItem>; export type DocumentListItem = z.infer<typeof DocumentListItem>;