diff --git a/apps/cli/src/knowledge/sync_calendar.ts b/apps/cli/src/knowledge/sync_calendar.ts index 98270ea1..f7ee3699 100644 --- a/apps/cli/src/knowledge/sync_calendar.ts +++ b/apps/cli/src/knowledge/sync_calendar.ts @@ -3,17 +3,28 @@ import path from 'path'; import { google } from 'googleapis'; import { authenticate } from '@google-cloud/local-auth'; import { OAuth2Client } from 'google-auth-library'; +import TurndownService from 'turndown'; // Configuration const CREDENTIALS_PATH = path.join(process.cwd(), 'credentials.json'); -const TOKEN_PATH = path.join(process.cwd(), 'token_calendar.json'); +const TOKEN_PATH = path.join(process.cwd(), 'token_calendar_notes.json'); // Changed to force re-auth with new scopes const SYNC_INTERVAL_MS = 60 * 1000; -const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']; +const SCOPES = [ + 'https://www.googleapis.com/auth/calendar.readonly', + 'https://www.googleapis.com/auth/drive.readonly' +]; + +// Initialize Turndown +const turndownService = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced' +}); // --- Auth Functions --- async function loadSavedCredentialsIfExist(): Promise { try { + if (!fs.existsSync(TOKEN_PATH)) return null; const tokenContent = fs.readFileSync(TOKEN_PATH, 'utf-8'); const tokenData = JSON.parse(tokenContent); @@ -21,17 +32,22 @@ async function loadSavedCredentialsIfExist(): Promise { const keys = JSON.parse(credsContent); const key = keys.installed || keys.web; - const credentials = { - type: 'authorized_user', - client_id: key.client_id, - client_secret: key.client_secret, + const client = new google.auth.OAuth2( + key.client_id, + key.client_secret, + key.redirect_uris ? key.redirect_uris[0] : 'http://localhost' + ); + + client.setCredentials({ refresh_token: tokenData.refresh_token || tokenData.refreshToken, access_token: tokenData.token || tokenData.access_token, - expiry_date: tokenData.expiry || tokenData.expiry_date - }; - return google.auth.fromJSON(credentials) as OAuth2Client; + expiry_date: tokenData.expiry || tokenData.expiry_date, + scope: tokenData.scope + }); + + return client; } catch (err) { - // console.error("Error loading saved credentials:", err); // Optional: silence if just not found + console.error("Error loading saved credentials:", err); return null; } } @@ -81,6 +97,12 @@ async function authorize(): Promise { return client!; } +// --- Helper Functions --- + +function cleanFilename(name: string): string { + return name.replace(/[\\/*?:\"<>|]/g, "").replace(/\s+/g, "_").substring(0, 100).trim(); +} + // --- Sync Logic --- function cleanUpOldFiles(currentEventIds: Set, syncDir: string) { @@ -88,13 +110,31 @@ function cleanUpOldFiles(currentEventIds: Set, syncDir: string) { const files = fs.readdirSync(syncDir); for (const filename of files) { - if (!filename.endsWith('.json') || filename === 'sync_state.json') continue; + if (filename === 'sync_state.json') continue; - const eventId = filename.replace('.json', ''); - if (!currentEventIds.has(eventId)) { + // We expect files like: + // {eventId}.json + // {eventId}_doc_{docId}.md + + let eventId: string | null = null; + + if (filename.endsWith('.json')) { + eventId = filename.replace('.json', ''); + } else if (filename.endsWith('.md')) { + // Try to extract eventId from prefix + // Assuming eventId doesn't contain underscores usually, but if it does, this split might be fragile. + // Google Calendar IDs are usually alphanumeric. + // Let's rely on the delimiter we use: "_doc_" + const parts = filename.split('_doc_'); + if (parts.length > 1) { + eventId = parts[0]; + } + } + + if (eventId && !currentEventIds.has(eventId)) { try { fs.unlinkSync(path.join(syncDir, filename)); - console.log(`Removed old/out-of-window event: ${eventId}`); + console.log(`Removed old/out-of-window file: ${filename}`); } catch (e) { console.error(`Error deleting file ${filename}:`, e); } @@ -117,11 +157,63 @@ async function saveEvent(event: any, syncDir: string): Promise { } } +async function processAttachments(drive: any, event: any, syncDir: string) { + if (!event.attachments || event.attachments.length === 0) return; + + const eventId = event.id; + const eventTitle = event.summary || 'Untitled'; + const eventDate = event.start?.dateTime || event.start?.date || 'Unknown'; + const organizer = event.organizer?.email || 'Unknown'; + + for (const att of event.attachments) { + // We only care about Google Docs + if (att.mimeType === 'application/vnd.google-apps.document') { + const fileId = att.fileId; + const safeTitle = cleanFilename(att.title); + // Unique filename linked to event + const filename = `${eventId}_doc_${safeTitle}.md`; + const filePath = path.join(syncDir, filename); + + // Simple cache check: if file exists, skip. + // Ideally we check modifiedTime, but that requires an extra API call per file. + // Given the loop interval, we can just check existence to save quota. + // If user updates notes, they might want them re-synced. + // For now, let's just check existence. To be smarter, we'd need a state file or check API. + if (fs.existsSync(filePath)) continue; + + try { + const res = await drive.files.export({ + fileId: fileId, + mimeType: 'text/html' + }); + + const html = res.data; + const md = turndownService.turndown(html); + + const frontmatter = [ + `# ${att.title}`, + `**Event:** ${eventTitle}`, + `**Date:** ${eventDate}`, + `**Organizer:** ${organizer}`, + `**Link:** ${att.fileUrl}`, + `---`, + `` + ].join('\n'); + + fs.writeFileSync(filePath, frontmatter + md); + console.log(`Synced Note: ${att.title} for event ${eventTitle}`); + } catch (e) { + console.error(`Failed to download note ${att.title}:`, e); + } + } + } +} + async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackDays: number) { - // Calculate window: -lookbackDays to +2 weeks + // Calculate window const now = new Date(); const lookbackMs = lookbackDays * 24 * 60 * 60 * 1000; - const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000; // Remaining constant as per original python script + const twoWeeksForwardMs = 14 * 24 * 60 * 60 * 1000; const timeMin = new Date(now.getTime() - lookbackMs).toISOString(); const timeMax = new Date(now.getTime() + twoWeeksForwardMs).toISOString(); @@ -129,6 +221,7 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD console.log(`Syncing calendar from ${timeMin} to ${timeMax} (lookback: ${lookbackDays} days)...`); const calendar = google.calendar({ version: 'v3', auth }); + const drive = google.drive({ version: 'v3', auth }); try { const res = await calendar.events.list({ @@ -149,6 +242,7 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD for (const event of events) { if (event.id) { await saveEvent(event, syncDir); + await processAttachments(drive, event, syncDir); currentEventIds.add(event.id); } } @@ -162,13 +256,13 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD } async function main() { - console.log("Starting Google Calendar Sync (TS)..."); + console.log("Starting Google Calendar & Notes Sync (TS)..."); const syncDirArg = process.argv[2]; const lookbackDaysArg = process.argv[3]; const SYNC_DIR = syncDirArg || 'synced_calendar_events'; - const LOOKBACK_DAYS = lookbackDaysArg ? parseInt(lookbackDaysArg, 10) : 14; // Default to 14 days + const LOOKBACK_DAYS = lookbackDaysArg ? parseInt(lookbackDaysArg, 10) : 14; if (isNaN(LOOKBACK_DAYS) || LOOKBACK_DAYS <= 0) { console.error("Error: Lookback days must be a positive number."); @@ -193,4 +287,4 @@ async function main() { } } -main().catch(console.error); +main().catch(console.error); \ No newline at end of file