code icon Code

Export Granola Transcripts

Export meeting transcripts from local Granola app to markdown files

Source Code

import fs from 'fs';
import https from 'https';
import zlib from 'zlib';
import path from 'path';
import os from 'os';
import { execSync } from 'child_process';

const [outputDir] = process.argv.slice(2);

const CREDS_PATH = path.join(os.homedir(), 'Library/Application Support/Granola/supabase.json');
const HEADERS = {
  'Content-Type': 'application/json',
  'Accept': '*/*',
  'Accept-Encoding': 'gzip, deflate',
  'User-Agent': 'Granola/5.354.0',
  'X-Client-Version': '5.354.0'
};

async function request(hostname, reqPath, body, token) {
  const postData = JSON.stringify(body);
  const options = {
    hostname,
    port: 443,
    path: reqPath,
    method: 'POST',
    headers: {
      ...HEADERS,
      'Authorization': `Bearer ${token}`,
      'Content-Length': Buffer.byteLength(postData)
    }
  };

  return new Promise((resolve, reject) => {
    const req = https.request(options, (res) => {
      let stream = res;
      if (res.headers['content-encoding'] === 'gzip') {
        stream = res.pipe(zlib.createGunzip());
      }
      const chunks = [];
      stream.on('data', chunk => chunks.push(chunk));
      stream.on('end', () => {
        const body = Buffer.concat(chunks).toString('utf8');
        resolve({ status: res.statusCode, body: body ? JSON.parse(body) : null });
      });
      stream.on('error', reject);
    });
    req.on('error', reject);
    req.write(postData);
    req.end();
  });
}

function prosemirrorToMarkdown(node, depth = 0) {
  if (!node) return '';
  if (node.type === 'text') return node.text || '';

  const children = (node.content || []).map(c => prosemirrorToMarkdown(c, depth)).join('');

  switch (node.type) {
    case 'heading':
      return '#'.repeat(node.attrs?.level || 1) + ' ' + children + '\n\n';
    case 'paragraph':
      return children + '\n\n';
    case 'bulletList':
      return (node.content || []).map(item => {
        const text = prosemirrorToMarkdown(item, depth + 1).trim();
        return '- ' + text;
      }).join('\n') + '\n\n';
    case 'orderedList':
      return (node.content || []).map((item, i) => {
        const text = prosemirrorToMarkdown(item, depth + 1).trim();
        return `${i + 1}. ` + text;
      }).join('\n') + '\n\n';
    case 'listItem':
      return children;
    case 'doc':
      return children;
    default:
      return children;
  }
}

function formatTranscript(segments) {
  return segments.map(seg => {
    const time = new Date(seg.start_timestamp).toISOString().slice(11, 19);
    const speaker = seg.source === 'assemblyai' ? '' : `${seg.source}: `;
    return `**[${time}]** ${speaker}${seg.text}`;
  }).join('\n\n');
}

async function main() {
  if (!outputDir) {
    console.error('Error: outputDir is required');
    console.error('Example: node granola-export.mjs ~/Desktop/granola-exports');
    process.exit(1);
  }

  // Load credentials
  if (!fs.existsSync(CREDS_PATH)) {
    console.error('Granola credentials not found. Make sure Granola is installed and you are logged in.');
    process.exit(1);
  }

  const creds = JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8'));
  const workosTokens = JSON.parse(creds.workos_tokens);
  let accessToken = workosTokens.access_token;

  // Create output directory
  fs.mkdirSync(outputDir, { recursive: true });

  // Load sync state
  const statePath = path.join(outputDir, '.granola-sync-state.json');
  let lastSync = null;
  if (fs.existsSync(statePath)) {
    const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
    lastSync = state.lastSync;
    console.log(`Incremental sync from ${lastSync}`);
  }

  // Fetch documents
  console.log('Fetching documents...');
  const docsRes = await request('api.granola.ai', '/v2/get-documents', {
    limit: 100,
    offset: 0,
    include_last_viewed_panel: true
  }, accessToken);

  if (docsRes.status !== 200) {
    console.error('Failed to fetch documents:', docsRes.status);
    process.exit(1);
  }

  let docs = docsRes.body.docs || [];

  // Filter to new/updated only
  if (lastSync) {
    docs = docs.filter(d => new Date(d.updated_at) > new Date(lastSync));
  }

  console.log(`Processing ${docs.length} documents...`);

  let latestUpdate = lastSync;

  for (const doc of docs) {
    const title = doc.title || 'Untitled';
    const date = doc.created_at?.slice(0, 10) || 'unknown';
    const filename = `${date} - ${title.replace(/[<>:"/\\|?*]/g, '_')}.md`;

    // Get attendees
    const attendees = doc.people?.attendees?.map(a =>
      a.details?.person?.name?.fullName || a.email
    ).filter(Boolean) || [];

    // Convert notes
    let notes = '';
    if (doc.last_viewed_panel?.content) {
      notes = prosemirrorToMarkdown(doc.last_viewed_panel.content).trim();
    }

    // Fetch transcript
    let transcript = '';
    try {
      const transcriptRes = await request('api.granola.ai', '/v1/get-document-transcript', {
        document_id: doc.id
      }, accessToken);

      if (transcriptRes.status === 200 && Array.isArray(transcriptRes.body)) {
        transcript = formatTranscript(transcriptRes.body);
      }
    } catch (e) {
      // No transcript available
    }

    // Build markdown
    const frontmatter = [
      '---',
      `granola_id: ${doc.id}`,
      `title: "${title.replace(/"/g, '\\"')}"`,
      `created_at: ${doc.created_at}`,
      `updated_at: ${doc.updated_at}`,
      `attendees: [${attendees.map(a => `"${a}"`).join(', ')}]`,
      `type: transcript`,
      '---'
    ].join('\n');

    let content = `${frontmatter}\n\n# ${title}\n\n`;

    if (notes) {
      content += `## Notes\n\n${notes}\n\n`;
    }

    if (transcript) {
      content += `## Transcript\n\n${transcript}\n`;
    }

    fs.writeFileSync(path.join(outputDir, filename), content);
    console.log(`  ✓ ${filename}`);

    if (!latestUpdate || new Date(doc.updated_at) > new Date(latestUpdate)) {
      latestUpdate = doc.updated_at;
    }
  }

  // Save sync state
  fs.writeFileSync(statePath, JSON.stringify({ lastSync: latestUpdate }, null, 2));

  // Open Finder to the export directory (macOS)
  try {
    execSync(`open "${outputDir}"`);
    console.log(`\nDone! ${docs.length} transcripts exported.`);
    console.log('Drag the files into Sauna to upload them.');
  } catch {
    console.log(`\nDone! ${docs.length} transcripts exported to ${outputDir}`);
    console.log('Open that folder and drag the files into Sauna.');
  }
}

main().catch(console.error);