# Notion Transcript Sync — Design

## Problem

kbx syncs meeting transcripts from Granola via `kb sync granola`. Notion's AI Meeting Notes also records transcripts for the same meetings. We want to pull Notion transcripts into kbx too, keeping both sources with proper deduplication.

## API Access

Notion's **public REST API** does not support the `transcription` block type (returns `unsupported`). The **Notion MCP server** in Claude can read transcripts but uses a proprietary internal API.

We use Notion's **internal API** (`www.notion.so/api/v3/*`) directly, authenticated with the `token_v2` session cookie from the Notion desktop app. This mirrors how the Granola sync uses Granola's internal API.

### Endpoints

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/v3/search` | POST | Find pages by creator, date, workspace (type: `BlocksInSpace`) |
| `/api/v3/loadPageChunk` | POST | Load page blocks + children (discovers `transcription` blocks) |
| `/api/v3/syncRecordValues` | POST | Fetch specific blocks/users by ID (batch requests) |

### Authentication

The `token_v2` cookie is stored in Notion's Electron app:

- **Path:** `~/Library/Application Support/Notion/Partitions/notion/Cookies` (SQLite)
- **Encryption:** AES-128-CBC, key from macOS Keychain ("Notion Safe Storage"), PBKDF2(SHA1, `saltysalt`, 1003 iters, 16 bytes), IV = 16 spaces, `v10` prefix
- **Token format:** JWE string `v03:eyJ...` (~791 chars)
- **Fallback:** `NOTION_TOKEN_V2` env var for headless/CI use

No token refresh needed — `token_v2` is a long-lived session cookie.

## Data Model

### Meeting structure

Notion meeting pages contain a single `transcription` child block:

```
page (type: "page")
  └── transcription (type: "transcription")
        ├── summary (type: "text", child blocks with AI summary)
        ├── notes (type: "text", child blocks with AI-generated notes)
        └── transcript (type: "text", child blocks = transcript segments)
```

### Transcription block format

```json
{
  "transcription_model": "bd",
  "transcription_state": {"state": "idle"},
  "transcription_transcript_id": "...",
  "transcription_summary_id": "...",
  "transcription_notes_id": "...",
  "transcription_calendar_event": {
    "uid": "5f0gi67uo8mv6u0i73s3ptiugq@google.com",
    "startTime": "2026-02-23T17:30:00.000+01:00",
    "endTime": "2026-02-23T18:00:00.000+01:00",
    "attendeeIds": ["d14f6a21-...", "7c94d173-..."]
  }
}
```

### Transcript segments

Each segment is a `text` block with speaker attribution:

```json
{
  "type": "text",
  "properties": {"title": [["The spoken text here"]]},
  "format": {"transcript_metadata": {"speaker_name": "speaker0"}}
}
```

Speaker names are generic (`speaker0`, `speaker1`, ...) — no mapping to attendee IDs.

### User resolution

`syncRecordValues` on `notion_user` table returns `name` + `email` for any Notion user UUID. Attendee IDs from `transcription_calendar_event.attendeeIds` resolve to full user records.

## Sync Pipeline

### Discovery

1. **Search** pages via `/api/v3/search` with filters: `createdBy` (current user), `createdTime` (since last sync or `--since`), workspace space ID
2. **Paginate** using `paginationToken` (100 results per page)
3. **Load each page** via `loadPageChunk` (limit=5, just enough to see child blocks)
4. **Filter** to pages with a `type: "transcription"` child block where `transcription_state.state == "idle"` (skip in-progress recordings)

### Content extraction

For each meeting:

1. Extract calendar event metadata from transcription block `format`
2. Batch-resolve all unique `attendeeIds` to names/emails via `syncRecordValues`
3. Fetch transcript segments by loading `transcription_transcript_id` block's children
4. Fetch AI summary from `transcription_summary_id` block
5. Fetch notes from `transcription_notes_id` block

### Incremental sync

- State file: `{data_dir}/.notion_sync_state.json` — tracks `last_sync` timestamp
- Early skip: compare page `last_edited_time` against `notion_updated_at` in existing file frontmatter
- Rate limiting: batch size 15, 0.5s delay between batches (same as Granola)

## File Naming Convention (New)

### Format

```
{uid_prefix}_{Sanitized_Title}.{source}.{type}.md
```

- **`uid_prefix`**: first 8 chars of Google Calendar UID. Falls back to first 8 chars of source-specific ID (Granola ID or Notion page ID) if no calendar event.
- **`source`**: `granola` or `notion`
- **`type`**: `notes` or `transcript`

### Examples

```
meetings/organised/2026/02/23/
  4eepe8es_Eng_recruitments_sync.granola.notes.md
  4eepe8es_Eng_recruitments_sync.granola.transcript.md
  4eepe8es_Eng_recruitments_sync.notion.notes.md
  4eepe8es_Eng_recruitments_sync.notion.transcript.md
```

Without a calendar event:

```
  b1b13f6f_Eng_recruitments_sync.granola.notes.md
  310a119c_Eng_recruitments_sync.notion.notes.md
```

### Migration

Existing Granola files (`{Title}_{granola_id}.notes.md`) are renamed on next sync. Detection: absence of `.granola.` or `.notion.` in filename. The Granola ID remains in frontmatter.

## Frontmatter

### Notion files

```yaml
title: Eng recruitments sync
date: '2026-02-23'
type: notes  # or transcript
source: notion-api
notion_page_id: 310a119c-1d9f-8008-91fd-f89f8d1a52db
notion_updated_at: '2026-02-23T10:30:00.000Z'
calendar_uid: 4eepe8esufbp47emrdv3ogsma6_R20260223T090000@google.com
tags:
- Jeremy
- Eric
attendees:
- name: Jeremy Brown
  email: jeremy.brown@gitguardian.com
- name: Eric Grabarczyk
  email: eric.grabarczyk@gitguardian.com
calendar_event:
  title: Eng recruitments sync
  scheduled_start: '2026-02-23T10:00:00+01:00'
  scheduled_end: '2026-02-23T10:30:00+01:00'
```

### Granola files (updated)

Add `calendar_uid` from the existing `iCalUID` field in Granola's `google_calendar_event` API response:

```yaml
calendar_uid: 4eepe8esufbp47emrdv3ogsma6_R20260223T090000@google.com
granola_id: b1b13f6f-b9ae-47cf-8374-d93768525200
```

## Deduplication

Both sources provide the Google Calendar `iCalUID`:
- Granola: `doc.google_calendar_event.iCalUID`
- Notion: `transcription_calendar_event.uid`

These are identical for the same meeting (verified). Files are **not deduplicated** — both sources are kept with different `.{source}.` in the filename. The `calendar_uid` frontmatter field links them. kbx search handles content-level dedup via similarity.

## CLI Interface

```
kb sync              # run all sync plugins (granola + notion)
kb sync granola      # granola only (existing)
kb sync notion       # notion only (new)
```

### `kb sync notion` options

| Flag | Description |
|------|-------------|
| `--since YYYY-MM-DD` | Sync meetings since date |
| `--dry-run` | Preview only, no file writes |
| `--force` | Overwrite files even if timestamps match |
| `--no-index` | Skip reindexing after sync |

### `kb sync` (all plugins)

Runs each plugin in sequence, aggregates results:

```
Syncing granola... 5 created, 2 updated, 10 skipped
Syncing notion... 3 created, 1 updated, 12 skipped
Done. 8 created, 3 updated, 22 skipped total.
```

## Implementation Scope

1. **`src/kb/sync/notion.py`** — `NotionClient` (cookie decryption, internal API), transcript/notes/summary extraction, frontmatter building, file writing, `sync_notion()` orchestrator
2. **`src/kb/cli.py`** — `kb sync notion` command + `kb sync` (all) command
3. **`src/kb/sync/granola.py`** — add `calendar_uid` to `build_frontmatter` from `iCalUID`
4. **File naming migration** — rename existing Granola files to new convention on next sync
5. **Entity matching** — reuse `match_or_create_attendee` from Granola sync (Notion attendees come with name + email from user resolution)
6. **Tests** — mirror Granola test structure in `tests/test_sync_notion.py`

## Dependencies

- `cryptography` — AES decryption of Electron cookies (already available in the Python ecosystem, add to `pyproject.toml`)
- `httpx` — HTTP client (already used by Granola sync)

## Risks

- **Internal API stability**: Notion's `/api/v3/` endpoints are undocumented and could change. Mitigated by: the desktop app depends on them too, so changes would be gradual.
- **Cookie encryption changes**: Electron's safe storage format could change across versions. Mitigated by: `NOTION_TOKEN_V2` env var fallback.
- **macOS only for cookie decryption**: The Keychain-based decryption only works on macOS. Linux/CI users must set `NOTION_TOKEN_V2` manually.
