mirror of
https://github.com/hanzoskill/granola.git
synced 2026-10-30 21:07:24 +00:00
240 lines
7.1 KiB
Python
240 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Granola meeting sync script.
|
|
Fetches all meetings from the Granola API and saves them locally.
|
|
|
|
Usage:
|
|
python sync.py [output_dir]
|
|
|
|
The script reads auth tokens from:
|
|
~/Library/Application Support/Granola/supabase.json
|
|
|
|
Output structure:
|
|
output_dir/
|
|
{meeting_id}/
|
|
metadata.json - title, date, attendees
|
|
transcript.md - formatted transcript
|
|
transcript.json - raw transcript data
|
|
document.json - full API response
|
|
notes.md - AI summary (if available)
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
# Paths
|
|
SUPABASE_PATH = Path.home() / "Library/Application Support/Granola/supabase.json"
|
|
DEFAULT_OUTPUT = Path.home() / "granola-meetings"
|
|
API_BASE = "https://api.granola.ai/v1"
|
|
|
|
|
|
def get_token():
|
|
"""Get access token from Granola's local auth file."""
|
|
if not SUPABASE_PATH.exists():
|
|
print(f"Error: Auth file not found at {SUPABASE_PATH}")
|
|
print("Make sure Granola is installed and you're signed in.")
|
|
sys.exit(1)
|
|
|
|
with open(SUPABASE_PATH) as f:
|
|
data = json.load(f)
|
|
|
|
tokens = json.loads(data.get("workos_tokens", "{}"))
|
|
token = tokens.get("access_token")
|
|
|
|
if not token:
|
|
print("Error: No access token found. Try signing into Granola again.")
|
|
sys.exit(1)
|
|
|
|
# Check expiration
|
|
obtained_at = tokens.get("obtained_at", 0) / 1000 # ms to seconds
|
|
expires_in = tokens.get("expires_in", 0)
|
|
if datetime.now().timestamp() > obtained_at + expires_in:
|
|
print("Warning: Token may be expired. Open Granola to refresh.")
|
|
|
|
return token
|
|
|
|
|
|
def fetch_documents(token, limit=500):
|
|
"""Fetch documents from Granola API."""
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
response = requests.post(
|
|
f"{API_BASE}/get-documents",
|
|
headers=headers,
|
|
json={"limit": limit}
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
|
|
def fetch_document_metadata(token, doc_id):
|
|
"""Fetch document metadata."""
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
response = requests.post(
|
|
f"{API_BASE}/get-document-metadata",
|
|
headers=headers,
|
|
json={"document_id": doc_id}
|
|
)
|
|
if response.ok:
|
|
return response.json()
|
|
return {}
|
|
|
|
|
|
def fetch_document_transcript(token, doc_id):
|
|
"""Fetch document transcript."""
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
response = requests.post(
|
|
f"{API_BASE}/get-document-transcript",
|
|
headers=headers,
|
|
json={"document_id": doc_id}
|
|
)
|
|
if response.ok:
|
|
return response.json()
|
|
return []
|
|
|
|
|
|
def format_transcript(doc, transcript_data=None):
|
|
"""Format transcript as readable markdown."""
|
|
lines = []
|
|
|
|
# Header
|
|
lines.append(f"# {doc.get('title', 'Untitled Meeting')}")
|
|
created = doc.get('created_at', 'Unknown')
|
|
lines.append(f"\n**Date:** {created[:10] if created else 'Unknown'}")
|
|
|
|
# Attendees - handle both dict and list formats
|
|
people = doc.get("people", {})
|
|
names = []
|
|
if isinstance(people, dict):
|
|
# New format: {creator: {...}, attendees: [...]}
|
|
creator = people.get("creator", {})
|
|
if creator.get("name"):
|
|
names.append(creator["name"])
|
|
for att in people.get("attendees", []):
|
|
if isinstance(att, dict) and att.get("name"):
|
|
names.append(att["name"])
|
|
elif isinstance(att, str):
|
|
names.append(att)
|
|
elif isinstance(people, list):
|
|
# Old format: [{name, email}, ...]
|
|
names = [p.get("name", p.get("email", "Unknown")) for p in people if isinstance(p, dict)]
|
|
|
|
if names:
|
|
lines.append(f"**Attendees:** {', '.join(names)}")
|
|
|
|
lines.append("\n---\n")
|
|
|
|
# Use provided transcript data if available, otherwise fall back to doc
|
|
transcript = transcript_data if transcript_data else (doc.get("transcript") or [])
|
|
|
|
if not transcript:
|
|
# Try chapters from doc
|
|
chapters = doc.get("chapters") or []
|
|
for chapter in chapters:
|
|
if chapter.get("title"):
|
|
lines.append(f"\n## {chapter['title']}\n")
|
|
for segment in chapter.get("transcript", []):
|
|
speaker = segment.get("speaker", "Unknown")
|
|
text = segment.get("text", "")
|
|
lines.append(f"**{speaker}:** {text}\n")
|
|
else:
|
|
# Handle transcript as list of segments
|
|
if isinstance(transcript, list):
|
|
for segment in transcript:
|
|
if isinstance(segment, dict):
|
|
speaker = segment.get("speaker", "Unknown")
|
|
text = segment.get("text", "")
|
|
lines.append(f"**{speaker}:** {text}\n")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def save_meeting(doc, output_dir, token=None):
|
|
"""Save a single meeting to disk."""
|
|
meeting_id = doc.get("id")
|
|
if not meeting_id:
|
|
return
|
|
|
|
meeting_dir = output_dir / meeting_id
|
|
meeting_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Fetch additional data if token provided
|
|
transcript_data = None
|
|
if token:
|
|
try:
|
|
transcript_data = fetch_document_transcript(token, meeting_id)
|
|
except Exception as e:
|
|
print(f" Warning: Could not fetch transcript: {e}")
|
|
|
|
# Metadata
|
|
metadata = {
|
|
"id": meeting_id,
|
|
"title": doc.get("title"),
|
|
"created_at": doc.get("created_at"),
|
|
"updated_at": doc.get("updated_at"),
|
|
"people": doc.get("people"),
|
|
"calendar_event": doc.get("google_calendar_event"),
|
|
}
|
|
with open(meeting_dir / "metadata.json", "w") as f:
|
|
json.dump(metadata, f, indent=2)
|
|
|
|
# Transcript markdown
|
|
with open(meeting_dir / "transcript.md", "w") as f:
|
|
f.write(format_transcript(doc, transcript_data))
|
|
|
|
# Raw transcript JSON
|
|
transcript = transcript_data or doc.get("transcript") or doc.get("chapters", [])
|
|
with open(meeting_dir / "transcript.json", "w") as f:
|
|
json.dump(transcript, f, indent=2)
|
|
|
|
# Full document
|
|
with open(meeting_dir / "document.json", "w") as f:
|
|
json.dump(doc, f, indent=2)
|
|
|
|
# Notes/summary if available
|
|
notes = doc.get("notes_markdown") or doc.get("notes_plain")
|
|
if notes:
|
|
with open(meeting_dir / "notes.md", "w") as f:
|
|
f.write(notes)
|
|
|
|
|
|
def main():
|
|
output_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_OUTPUT
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
print(f"Syncing Granola meetings to {output_dir}")
|
|
|
|
token = get_token()
|
|
|
|
print("Fetching documents from API...")
|
|
docs = fetch_documents(token)
|
|
|
|
print(f"Found {len(docs)} meetings")
|
|
|
|
for i, doc in enumerate(docs, 1):
|
|
title = doc.get("title", "Untitled")[:50]
|
|
print(f" [{i}/{len(docs)}] {title}")
|
|
save_meeting(doc, output_dir, token)
|
|
|
|
print(f"\nDone! Meetings saved to {output_dir}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|