Granola Meeting Notes
Extract and query meeting data from Granola's local cache on macOS and Windows.
Prerequisites
- •Granola must be installed (macOS or Windows)
- •User must have at least one recorded meeting
Note: Windows support is untested. The cache path is assumed to be %LOCALAPPDATA%\Granola\cache-v3.json.
Cache Location
| Platform | Path |
|---|---|
| macOS | ~/Library/Application Support/Granola/cache-v3.json |
| Windows | %LOCALAPPDATA%\Granola\cache-v3.json |
Reading the Cache
python
import json
import os
import platform
from datetime import datetime, timedelta, timezone
from collections import Counter
def get_cache_path():
"""Get Granola cache path for current platform."""
if platform.system() == 'Windows':
local_appdata = os.environ.get('LOCALAPPDATA', '')
return os.path.join(local_appdata, 'Granola', 'cache-v3.json')
else: # macOS (and Linux if ever supported)
return os.path.expanduser('~/Library/Application Support/Granola/cache-v3.json')
cache_path = get_cache_path()
if not os.path.exists(cache_path):
raise FileNotFoundError(f"Granola cache not found at {cache_path}. Is Granola installed with recorded meetings?")
with open(cache_path, 'r') as f:
raw = json.load(f)
# Handle nested 'cache' key containing stringified JSON
if 'cache' in raw and isinstance(raw['cache'], str):
cache = json.loads(raw['cache'])
else:
cache = raw
state = cache.get('state', cache)
Cache Structure
| Section | Description |
|---|---|
documents | Meeting metadata and calendar event details |
transcripts | Full transcripts with speaker identification |
documentPanels | AI-generated summaries and user notes |
Extracting Meeting Data
python
documents = state.get('documents', {})
transcripts = state.get('transcripts', {})
panels = state.get('documentPanels', {})
meetings = []
for doc_id, doc in documents.items():
event = doc.get('calendarEvent', {})
panel_data = panels.get(doc_id, {})
transcript_data = transcripts.get(doc_id, {})
meeting = {
'id': doc_id,
'title': doc.get('title', 'Untitled'),
'created_at': doc.get('createdAt'),
'attendees': event.get('attendees', []),
'location': event.get('location', ''),
'organizer': event.get('organizer', {}).get('email', ''),
'user_notes': panel_data.get('userNotes', ''),
'ai_summary': panel_data.get('aiSummary', ''),
'transcript': transcript_data,
}
# Calculate transcript statistics
if transcript_data:
segments = transcript_data.get('segments', [])
meeting['transcript_stats'] = calculate_transcript_stats(segments)
meetings.append(meeting)
# Sort by date (newest first)
meetings.sort(key=lambda m: m.get('created_at', ''), reverse=True)
Transcript Statistics
python
def calculate_transcript_stats(segments):
"""Calculate word count, speaker count, and duration from transcript segments."""
if not segments:
return {'word_count': 0, 'speakers': [], 'duration_seconds': 0}
words = 0
speakers = set()
for seg in segments:
text = seg.get('text', '')
words += len(text.split())
speaker = seg.get('speaker')
if speaker:
speakers.add(speaker)
# Calculate duration from first/last segment timestamps
duration = 0
if len(segments) >= 2:
try:
start = segments[0].get('start', 0)
end = segments[-1].get('end', 0)
duration = end - start
except (TypeError, KeyError):
pass
return {
'word_count': words,
'speakers': list(speakers),
'speaker_count': len(speakers),
'duration_seconds': duration,
'duration_formatted': f"{duration // 60}m {duration % 60}s" if duration else 'N/A'
}
Fuzzy Search with Weighted Scoring
Use fuzzy matching for flexible search. Title matches are weighted higher than participant matches.
python
from difflib import SequenceMatcher
def fuzzy_match(query, text, threshold=0.6):
"""Return similarity score if above threshold, else 0."""
if not query or not text:
return 0
ratio = SequenceMatcher(None, query.lower(), text.lower()).ratio()
return ratio if ratio >= threshold else 0
def search_meetings(meetings, query, search_fields=None):
"""
Search meetings with weighted scoring.
search_fields: list of 'title', 'attendees', 'transcript', 'notes'
Default: ['title', 'attendees']
Scoring:
- Title match: 2 points * similarity
- Attendee match: 1 point * similarity
- Notes/summary match: 1 point * similarity
- Transcript match: 0.5 points * similarity
"""
if search_fields is None:
search_fields = ['title', 'attendees']
results = []
query_lower = query.lower()
for m in meetings:
score = 0
match_type = None
# Title search (weight: 2x)
if 'title' in search_fields:
title = m.get('title', '')
title_score = fuzzy_match(query, title)
if title_score > 0:
score += title_score * 2
match_type = 'title'
# Attendee search (weight: 1x)
if 'attendees' in search_fields:
for att in m.get('attendees', []):
email = att.get('email', '')
name = email.split('@')[0]
att_score = max(fuzzy_match(query, email), fuzzy_match(query, name))
if att_score > 0:
score += att_score
match_type = match_type or 'attendee'
# Notes/summary search (weight: 1x)
if 'notes' in search_fields:
notes = str(m.get('user_notes', '')) + str(m.get('ai_summary', ''))
if query_lower in notes.lower():
score += 1
match_type = match_type or 'notes'
# Transcript search (weight: 0.5x)
if 'transcript' in search_fields:
transcript = m.get('transcript', {})
segments = transcript.get('segments', [])
transcript_text = ' '.join(s.get('text', '') for s in segments)
if query_lower in transcript_text.lower():
score += 0.5
match_type = match_type or 'transcript'
if score > 0:
results.append({**m, '_score': score, '_match_type': match_type})
# Sort by score descending, then by date
results.sort(key=lambda x: (-x['_score'], x.get('created_at', '')))
return results
Pattern Analysis
Participant Frequency
python
def analyze_participant_frequency(meetings, days=30):
"""Find most frequent meeting participants in the last N days."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
counter = Counter()
for m in meetings:
created = m.get('created_at')
if not created:
continue
try:
dt = datetime.fromisoformat(created.replace('Z', '+00:00'))
if dt < cutoff:
continue
except ValueError:
continue
for att in m.get('attendees', []):
email = att.get('email', '')
if email:
counter[email] += 1
return counter.most_common(10)
Meeting Frequency by Week
python
def analyze_meeting_frequency(meetings, weeks=8):
"""Analyze meeting count per week."""
cutoff = datetime.now(timezone.utc) - timedelta(weeks=weeks)
weekly = Counter()
for m in meetings:
created = m.get('created_at')
if not created:
continue
try:
dt = datetime.fromisoformat(created.replace('Z', '+00:00'))
if dt < cutoff:
continue
week_key = dt.strftime('%Y-W%W')
weekly[week_key] += 1
except ValueError:
continue
return sorted(weekly.items())
Topic Extraction
python
def extract_topics(meetings, days=30):
"""Extract common words from meeting titles as topic indicators."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
words = Counter()
stopwords = {'the', 'a', 'an', 'and', 'or', 'with', 'for', 'to', 'of', 'in', 'on', 'at', 'by'}
for m in meetings:
created = m.get('created_at')
if not created:
continue
try:
dt = datetime.fromisoformat(created.replace('Z', '+00:00'))
if dt < cutoff:
continue
except ValueError:
continue
title = m.get('title', '').lower()
for word in title.split():
word = word.strip('.,!?:;()[]{}')
if len(word) > 2 and word not in stopwords:
words[word] += 1
return words.most_common(15)
Export to Markdown
python
import re
def slugify(text):
"""Convert text to URL-friendly slug."""
text = text.lower().strip()
text = re.sub(r'[^\w\s-]', '', text)
text = re.sub(r'[-\s]+', '-', text)
return text[:50]
def export_meeting_to_markdown(meeting, output_dir='~/granola-exports'):
"""Export a meeting to a markdown file with auto-generated filename."""
output_dir = os.path.expanduser(output_dir)
os.makedirs(output_dir, exist_ok=True)
# Generate filename: YYYY-MM-DD-title-slug.md
date_str = meeting.get('created_at', '')[:10]
title_slug = slugify(meeting.get('title', 'untitled'))
filename = f"{date_str}-{title_slug}.md"
filepath = os.path.join(output_dir, filename)
# Format attendees
attendees = [a.get('email', '').split('@')[0] for a in meeting.get('attendees', [])]
# Get transcript stats
stats = meeting.get('transcript_stats', {})
content = f"""# {meeting.get('title', 'Untitled')}
**Date:** {format_date(meeting.get('created_at'))}
**Attendees:** {', '.join(attendees) or 'N/A'}
**Location:** {meeting.get('location') or 'N/A'}
**Organizer:** {meeting.get('organizer') or 'N/A'}
## Statistics
- **Duration:** {stats.get('duration_formatted', 'N/A')}
- **Word Count:** {stats.get('word_count', 'N/A')}
- **Speakers:** {stats.get('speaker_count', 'N/A')}
## Notes
{meeting.get('user_notes') or '_No notes_'}
## AI Summary
{meeting.get('ai_summary') or '_No summary_'}
"""
with open(filepath, 'w') as f:
f.write(content)
return filepath
Timezone-Aware Date Formatting
python
def format_date(iso_string, format_str='%B %d, %Y at %I:%M %p'):
"""Format ISO date string to local timezone."""
if not iso_string:
return 'Unknown'
try:
dt = datetime.fromisoformat(iso_string.replace('Z', '+00:00'))
local_dt = dt.astimezone() # Convert to local timezone
return local_dt.strftime(format_str)
except ValueError:
return iso_string[:10]
Common Queries
List Recent Meetings
python
days = 7
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
recent = [m for m in meetings
if m.get('created_at') and
datetime.fromisoformat(m['created_at'].replace('Z', '+00:00')) > cutoff]
for m in recent:
stats = m.get('transcript_stats', {})
print(f"- {m['title']} ({format_date(m['created_at'], '%b %d')})")
print(f" Duration: {stats.get('duration_formatted', 'N/A')}, "
f"Speakers: {stats.get('speaker_count', 'N/A')}")
Find Meetings by Attendee
python
results = search_meetings(meetings, 'john', search_fields=['attendees'])
Search All Fields
python
results = search_meetings(meetings, 'budget review',
search_fields=['title', 'attendees', 'notes', 'transcript'])
Who Do I Meet With Most?
python
top_participants = analyze_participant_frequency(meetings, days=30)
for email, count in top_participants:
print(f"- {email}: {count} meetings")
Meeting Patterns
python
# Weekly frequency
weekly = analyze_meeting_frequency(meetings, weeks=8)
for week, count in weekly:
print(f"- {week}: {count} meetings")
# Common topics
topics = extract_topics(meetings, days=30)
for topic, count in topics:
print(f"- {topic}: {count} occurrences")
Output Formatting
Detailed Meeting View
markdown
## {title}
**Date:** {formatted_date}
**Attendees:** {comma_separated_names}
**Location:** {location}
**Duration:** {duration} | **Words:** {word_count} | **Speakers:** {speaker_count}
### Notes
{user_notes}
### AI Summary
{ai_summary}
Compact List View
markdown
| Date | Meeting | Attendees | Duration | |------|---------|-----------|----------| | Jan 20 | Weekly Standup | alice, bob | 45m | | Jan 19 | Product Review | dave, eve | 1h 15m |
Pattern Analysis View
markdown
## Meeting Patterns (Last 30 Days) ### Top Collaborators 1. alice@company.com - 12 meetings 2. bob@company.com - 8 meetings 3. charlie@company.com - 6 meetings ### Weekly Trend - Week 3: 8 meetings - Week 2: 6 meetings - Week 1: 10 meetings ### Common Topics - standup (15), review (8), planning (6), sync (5)
Error Handling
python
if not os.path.exists(cache_path):
print("Granola cache not found. Is Granola installed with recorded meetings?")
elif not meetings:
print("No meetings found in Granola cache.")
Privacy Note
All data stays local. The cache file contains meeting transcripts and notes that may be sensitive. Never transmit this data externally.