Skip to content
← Public packages

@charlesmatte/granola

Search and read Granola meeting notes, summaries and transcripts from any agent; manage webhooks and legal holds safely.

src/notes/list.ts

71 lines · 3.3 KB · TypeScript
Raw
import { listEndpoint, pageSpec, parseWithAccount } from '../lib/api.ts'
import { DATE_PATTERN, FOLDER_ID_PATTERN } from '../lib/validate.ts'

type NoteSummary = { id: string; title: string | null; owner?: { name?: string | null; email?: string }; created_at: string; updated_at: string }

/**
 * List Granola meeting notes the API key can see (newest first), following cursors until `limit`.
 * Wraps `GET /v1/notes`. Only notes with a generated AI summary and transcript are returned.
 * Use `updatedAfter` for incremental sync and `folderId` (from ./folders/list) to scope to a
 * folder and its subfolders. `titleContains` / `ownerEmail` filter the fetched page(s) locally
 * (the API has no text search), so combine them with a date window for large workspaces.
 *
 * @param input.account - Account name (Kody secret `granola-<account>` holds the API key). Required.
 * @param input.secretName - Use this Kody secret instead of `granola-<account>`.
 * @param input.createdAfter - `YYYY-MM-DD` or ISO date-time. @param input.createdBefore - same.
 * @param input.updatedAfter - same; notes edited after this time.
 * @param input.folderId - `fol_…` id; includes child folders.
 * @param input.titleContains - Case-insensitive local filter on the title.
 * @param input.ownerEmail - Case-insensitive local filter on the owner's email.
 * @param input.limit - Max notes to fetch (1-5000, default 30). @param input.paginate - Follow cursors (default true).
 * @param input.cursor - Continue from a previous result's `cursor`.
 * @returns `{ account, count, notes: [{ id, title, owner, created_at, updated_at }], hasMore, cursor, pages, filteredOut }`
 *
 * @example
 * import listNotes from 'kody:@charlesmatte/granola/notes/list'
 *
 * const recent = await listNotes({ account: 'work', createdAfter: '2026-09-01', limit: 50 })
 */
export default async function listGranolaNotes(input: unknown) {
	const p = parseWithAccount<{
		createdAfter?: string; createdBefore?: string; updatedAfter?: string; folderId?: string
		titleContains?: string; ownerEmail?: string; limit?: number; cursor?: string; paginate?: boolean
	}>(
		input,
		{
			createdAfter: { type: 'string', pattern: DATE_PATTERN },
			createdBefore: { type: 'string', pattern: DATE_PATTERN },
			updatedAfter: { type: 'string', pattern: DATE_PATTERN },
			folderId: { type: 'string', pattern: FOLDER_ID_PATTERN },
			titleContains: { type: 'string', maxLength: 200 },
			ownerEmail: { type: 'string', maxLength: 320 },
			...pageSpec(5000),
		},
		'notes/list',
	)
	const r = await listEndpoint<NoteSummary>(p.acct, {
		path: '/v1/notes',
		itemsKey: 'notes',
		query: { created_after: p.createdAfter, created_before: p.createdBefore, updated_after: p.updatedAfter, folder_id: p.folderId },
		limit: p.limit ?? 30,
		maxPageSize: 30,
		cursor: p.cursor,
		paginate: p.paginate ?? true,
	})
	const title = p.titleContains?.toLowerCase()
	const owner = p.ownerEmail?.toLowerCase()
	const notes = r.items.filter(
		(n) => (!title || (n.title ?? '').toLowerCase().includes(title)) && (!owner || (n.owner?.email ?? '').toLowerCase() === owner),
	)
	return {
		account: p.acct.account,
		count: notes.length,
		notes,
		fetched: r.count,
		filteredOut: r.count - notes.length,
		hasMore: r.hasMore,
		cursor: r.cursor,
		pages: r.pages,
		...(r.truncated ? { note: 'limit reached mid-page; continuing from cursor may skip the rest of that page' } : {}),
	}
}