d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
466 lines
15 KiB
TypeScript
466 lines
15 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage, toError } from '@sim/utils/errors'
|
|
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
|
|
import { granolaConnectorMeta } from '@/connectors/granola/meta'
|
|
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
|
|
import { htmlToPlainText, joinTagArray, parseTagDate } from '@/connectors/utils'
|
|
|
|
const logger = createLogger('GranolaConnector')
|
|
|
|
const GRANOLA_API_BASE = 'https://public-api.granola.ai/v1'
|
|
/** Granola caps page_size at 30; request the maximum to minimize round trips. */
|
|
const PAGE_SIZE = 30
|
|
|
|
/** Granola folder identifiers match `fol_` followed by 14 alphanumeric chars. */
|
|
const FOLDER_ID_PATTERN = /^fol_[a-zA-Z0-9]{14}$/
|
|
|
|
/**
|
|
* A note owner or attendee as returned by the Granola API.
|
|
*/
|
|
interface GranolaUser {
|
|
name: string | null
|
|
email: string
|
|
}
|
|
|
|
/**
|
|
* The lightweight note shape returned by the list endpoint. It contains only
|
|
* metadata — no summary or transcript content — so content must be fetched per
|
|
* note via the get endpoint (the deferred-content pattern).
|
|
*/
|
|
interface GranolaNoteSummary {
|
|
id: string
|
|
object?: string
|
|
title: string | null
|
|
owner?: GranolaUser
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
/**
|
|
* A folder the note belongs to, as returned by the get endpoint.
|
|
*/
|
|
interface GranolaFolderMembership {
|
|
id: string
|
|
name: string
|
|
parent_folder_id?: string | null
|
|
}
|
|
|
|
/**
|
|
* Calendar event details attached to a note, when available.
|
|
* Field names match the Granola API's CalendarEvent schema.
|
|
*/
|
|
interface GranolaCalendarEvent {
|
|
event_title?: string | null
|
|
organiser?: string | null
|
|
calendar_event_id?: string | null
|
|
scheduled_start_time?: string | null
|
|
scheduled_end_time?: string | null
|
|
invitees?: { email: string }[]
|
|
}
|
|
|
|
/**
|
|
* The full note shape returned by the get endpoint, including summary content.
|
|
*/
|
|
interface GranolaNoteDetail extends GranolaNoteSummary {
|
|
web_url?: string | null
|
|
calendar_event?: GranolaCalendarEvent | null
|
|
attendees?: GranolaUser[]
|
|
folder_membership?: GranolaFolderMembership[]
|
|
summary_text?: string | null
|
|
summary_markdown?: string | null
|
|
}
|
|
|
|
/**
|
|
* The list endpoint response envelope.
|
|
*/
|
|
interface GranolaListNotesResponse {
|
|
notes?: GranolaNoteSummary[]
|
|
hasMore?: boolean
|
|
cursor?: string | null
|
|
}
|
|
|
|
/**
|
|
* Builds the authorization headers for a Granola API request.
|
|
*/
|
|
function granolaHeaders(accessToken: string): Record<string, string> {
|
|
return {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Produces the change-detection hash for a note from its stable identifiers.
|
|
* Granola exposes `updated_at`, which advances whenever the note (or its summary)
|
|
* changes, so a metadata-only hash is sufficient and stays identical between the
|
|
* list stub and the fetched document — letting the sync engine skip re-fetching
|
|
* unchanged notes.
|
|
*/
|
|
function buildContentHash(id: string, updatedAt: string): string {
|
|
return `granola:${id}:${updatedAt}`
|
|
}
|
|
|
|
/**
|
|
* Parses the optional `maxNotes` cap from source config.
|
|
* Returns 0 (unlimited) when unset or invalid.
|
|
*/
|
|
function parseMaxNotes(sourceConfig: Record<string, unknown>): number {
|
|
const raw = sourceConfig.maxNotes
|
|
if (raw == null || raw === '') return 0
|
|
const num = Number(raw)
|
|
return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0
|
|
}
|
|
|
|
/**
|
|
* Parses the optional `folderId` scope from source config. Returns a trimmed
|
|
* folder id only when it matches Granola's `fol_…` identifier shape; otherwise
|
|
* returns undefined so the request is not scoped to an invalid folder.
|
|
*/
|
|
function parseFolderId(sourceConfig: Record<string, unknown>): string | undefined {
|
|
const raw = sourceConfig.folderId
|
|
if (typeof raw !== 'string') return undefined
|
|
const trimmed = raw.trim()
|
|
if (!trimmed) return undefined
|
|
return FOLDER_ID_PATTERN.test(trimmed) ? trimmed : undefined
|
|
}
|
|
|
|
/**
|
|
* Parses an optional ISO 8601 date filter from a named source-config field.
|
|
* Returns a normalized ISO 8601 string when the value is a valid date; otherwise
|
|
* returns undefined so the request is not scoped to an invalid date.
|
|
*/
|
|
function parseDateFilter(sourceConfig: Record<string, unknown>, key: string): string | undefined {
|
|
const raw = sourceConfig[key]
|
|
if (typeof raw !== 'string') return undefined
|
|
const trimmed = raw.trim()
|
|
if (!trimmed) return undefined
|
|
const parsed = new Date(trimmed)
|
|
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString()
|
|
}
|
|
|
|
/**
|
|
* Detects whether a string contains HTML markup. Granola returns markdown for
|
|
* `summary_markdown`, but this guard lets us defensively strip tags if the API
|
|
* ever emits HTML, without mangling legitimate markdown.
|
|
*/
|
|
function looksLikeHtml(value: string): boolean {
|
|
return /<\/?[a-z][\s\S]*?>/i.test(value)
|
|
}
|
|
|
|
/**
|
|
* Assembles the document content from a note's title and summary. Prefers the
|
|
* markdown summary, falling back to plain-text summary. HTML is stripped only
|
|
* when detected so markdown formatting is preserved.
|
|
*/
|
|
function buildContent(note: GranolaNoteDetail): string {
|
|
const parts: string[] = []
|
|
|
|
const title = note.title?.trim()
|
|
if (title) parts.push(`# ${title}`)
|
|
|
|
const rawSummary = note.summary_markdown?.trim() || note.summary_text?.trim() || ''
|
|
if (rawSummary) {
|
|
parts.push('')
|
|
parts.push(looksLikeHtml(rawSummary) ? htmlToPlainText(rawSummary) : rawSummary)
|
|
}
|
|
|
|
return parts.join('\n').trim()
|
|
}
|
|
|
|
/**
|
|
* Resolves an owner's display name, falling back to email, for tag mapping.
|
|
*/
|
|
function ownerDisplay(owner?: GranolaUser): string | undefined {
|
|
if (!owner) return undefined
|
|
return owner.name?.trim() || owner.email?.trim() || undefined
|
|
}
|
|
|
|
/**
|
|
* Collects attendee display names (falling back to email) for tag mapping.
|
|
*/
|
|
function collectAttendees(note: GranolaNoteDetail): string[] {
|
|
if (!Array.isArray(note.attendees)) return []
|
|
return note.attendees
|
|
.map((a) => a.name?.trim() || a.email?.trim() || '')
|
|
.filter((name): name is string => name.length > 0)
|
|
}
|
|
|
|
/**
|
|
* Collects folder names for tag mapping.
|
|
*/
|
|
function collectFolders(note: GranolaNoteDetail): string[] {
|
|
if (!Array.isArray(note.folder_membership)) return []
|
|
return note.folder_membership
|
|
.map((f) => f.name?.trim() || '')
|
|
.filter((name): name is string => name.length > 0)
|
|
}
|
|
|
|
/**
|
|
* Builds the deferred stub for a note from list metadata. Content is empty and
|
|
* fetched later via `getDocument` only for new/changed notes.
|
|
*/
|
|
function noteSummaryToStub(note: GranolaNoteSummary): ExternalDocument {
|
|
return {
|
|
externalId: note.id,
|
|
title: note.title?.trim() || 'Untitled Note',
|
|
content: '',
|
|
contentDeferred: true,
|
|
mimeType: 'text/markdown',
|
|
contentHash: buildContentHash(note.id, note.updated_at),
|
|
metadata: {
|
|
title: note.title?.trim() || undefined,
|
|
owner: ownerDisplay(note.owner),
|
|
ownerName: note.owner?.name ?? undefined,
|
|
ownerEmail: note.owner?.email ?? undefined,
|
|
noteDate: note.created_at,
|
|
updatedAt: note.updated_at,
|
|
},
|
|
}
|
|
}
|
|
|
|
export const granolaConnector: ConnectorConfig = {
|
|
...granolaConnectorMeta,
|
|
|
|
listDocuments: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>,
|
|
cursor?: string,
|
|
syncContext?: Record<string, unknown>,
|
|
lastSyncAt?: Date
|
|
): Promise<ExternalDocumentList> => {
|
|
const maxNotes = parseMaxNotes(sourceConfig)
|
|
const folderId = parseFolderId(sourceConfig)
|
|
const createdAfter = parseDateFilter(sourceConfig, 'createdAfter')
|
|
const createdBefore = parseDateFilter(sourceConfig, 'createdBefore')
|
|
|
|
const url = new URL(`${GRANOLA_API_BASE}/notes`)
|
|
url.searchParams.set('page_size', String(PAGE_SIZE))
|
|
if (cursor) url.searchParams.set('cursor', cursor)
|
|
if (lastSyncAt) url.searchParams.set('updated_after', lastSyncAt.toISOString())
|
|
if (folderId) url.searchParams.set('folder_id', folderId)
|
|
if (createdAfter) url.searchParams.set('created_after', createdAfter)
|
|
if (createdBefore) url.searchParams.set('created_before', createdBefore)
|
|
|
|
logger.info('Listing Granola notes', {
|
|
hasCursor: Boolean(cursor),
|
|
incremental: Boolean(lastSyncAt),
|
|
scopedToFolder: Boolean(folderId),
|
|
scopedByCreatedAfter: Boolean(createdAfter),
|
|
scopedByCreatedBefore: Boolean(createdBefore),
|
|
})
|
|
|
|
const response = await fetchWithRetry(url.toString(), {
|
|
method: 'GET',
|
|
headers: granolaHeaders(accessToken),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '')
|
|
logger.error('Failed to list Granola notes', {
|
|
status: response.status,
|
|
error: errorText.slice(0, 500),
|
|
})
|
|
throw new Error(`Failed to list Granola notes: ${response.status}`)
|
|
}
|
|
|
|
const data = (await response.json()) as GranolaListNotesResponse
|
|
const notes = Array.isArray(data.notes) ? data.notes : []
|
|
const nextCursor = data.cursor?.trim() || undefined
|
|
|
|
const allStubs = notes.filter((note) => Boolean(note.id)).map((note) => noteSummaryToStub(note))
|
|
|
|
const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0
|
|
let documents = allStubs
|
|
if (maxNotes > 0) {
|
|
const remaining = Math.max(0, maxNotes - prevFetched)
|
|
if (allStubs.length > remaining) {
|
|
documents = allStubs.slice(0, remaining)
|
|
}
|
|
}
|
|
|
|
const totalFetched = prevFetched + documents.length
|
|
if (syncContext) syncContext.totalDocsFetched = totalFetched
|
|
|
|
const hitLimit = maxNotes > 0 && totalFetched >= maxNotes
|
|
if (hitLimit && syncContext) syncContext.listingCapped = true
|
|
|
|
const hasMore = !hitLimit && Boolean(data.hasMore) && Boolean(nextCursor)
|
|
|
|
return {
|
|
documents,
|
|
nextCursor: hasMore ? nextCursor : undefined,
|
|
hasMore,
|
|
}
|
|
},
|
|
|
|
getDocument: async (
|
|
accessToken: string,
|
|
_sourceConfig: Record<string, unknown>,
|
|
externalId: string
|
|
): Promise<ExternalDocument | null> => {
|
|
try {
|
|
if (!externalId) return null
|
|
|
|
const url = `${GRANOLA_API_BASE}/notes/${encodeURIComponent(externalId)}`
|
|
|
|
const response = await fetchWithRetry(url, {
|
|
method: 'GET',
|
|
headers: granolaHeaders(accessToken),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404 || response.status === 410) return null
|
|
throw new Error(`Failed to fetch Granola note: ${response.status}`)
|
|
}
|
|
|
|
const note = (await response.json()) as GranolaNoteDetail
|
|
if (!note?.id) return null
|
|
|
|
const content = buildContent(note)
|
|
if (!content) {
|
|
logger.info('Granola note has no content', { externalId })
|
|
return null
|
|
}
|
|
|
|
const attendees = collectAttendees(note)
|
|
const folders = collectFolders(note)
|
|
const meeting = note.calendar_event?.event_title?.trim() || undefined
|
|
const meetingDate = note.calendar_event?.scheduled_start_time?.trim() || undefined
|
|
|
|
return {
|
|
externalId: note.id,
|
|
title: note.title?.trim() || 'Untitled Note',
|
|
content,
|
|
contentDeferred: false,
|
|
mimeType: 'text/markdown',
|
|
sourceUrl: note.web_url?.trim() || undefined,
|
|
contentHash: buildContentHash(note.id, note.updated_at),
|
|
metadata: {
|
|
title: note.title?.trim() || undefined,
|
|
owner: ownerDisplay(note.owner),
|
|
ownerName: note.owner?.name ?? undefined,
|
|
ownerEmail: note.owner?.email ?? undefined,
|
|
noteDate: note.created_at,
|
|
updatedAt: note.updated_at,
|
|
attendees,
|
|
folders,
|
|
meeting,
|
|
meetingDate,
|
|
},
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Failed to get Granola note', {
|
|
externalId,
|
|
error: toError(error).message,
|
|
})
|
|
return null
|
|
}
|
|
},
|
|
|
|
validateConfig: async (
|
|
accessToken: string,
|
|
sourceConfig: Record<string, unknown>
|
|
): Promise<{ valid: boolean; error?: string }> => {
|
|
const maxNotes = sourceConfig.maxNotes as string | undefined
|
|
if (maxNotes && (Number.isNaN(Number(maxNotes)) || Number(maxNotes) < 0)) {
|
|
return { valid: false, error: 'Max notes must be a non-negative number' }
|
|
}
|
|
|
|
const folderId = sourceConfig.folderId
|
|
if (
|
|
typeof folderId === 'string' &&
|
|
folderId.trim() &&
|
|
!FOLDER_ID_PATTERN.test(folderId.trim())
|
|
) {
|
|
return {
|
|
valid: false,
|
|
error:
|
|
'Folder ID must look like fol_ followed by 14 alphanumeric characters (e.g. fol_4y6LduVdwSKC27)',
|
|
}
|
|
}
|
|
|
|
const createdAfter = sourceConfig.createdAfter
|
|
if (
|
|
typeof createdAfter === 'string' &&
|
|
createdAfter.trim() &&
|
|
Number.isNaN(new Date(createdAfter.trim()).getTime())
|
|
) {
|
|
return {
|
|
valid: false,
|
|
error:
|
|
'Created After must be a valid date (ISO 8601, e.g. 2025-01-01 or 2025-01-01T00:00:00Z)',
|
|
}
|
|
}
|
|
|
|
const createdBefore = sourceConfig.createdBefore
|
|
if (
|
|
typeof createdBefore === 'string' &&
|
|
createdBefore.trim() &&
|
|
Number.isNaN(new Date(createdBefore.trim()).getTime())
|
|
) {
|
|
return {
|
|
valid: false,
|
|
error:
|
|
'Created Before must be a valid date (ISO 8601, e.g. 2025-12-31 or 2025-12-31T23:59:59Z)',
|
|
}
|
|
}
|
|
|
|
try {
|
|
const url = new URL(`${GRANOLA_API_BASE}/notes`)
|
|
url.searchParams.set('page_size', '1')
|
|
|
|
const response = await fetchWithRetry(
|
|
url.toString(),
|
|
{
|
|
method: 'GET',
|
|
headers: granolaHeaders(accessToken),
|
|
},
|
|
VALIDATE_RETRY_OPTIONS
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '')
|
|
return {
|
|
valid: false,
|
|
error: `Granola access failed: ${response.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`,
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
} catch (error) {
|
|
const message = getErrorMessage(error, 'Failed to validate configuration')
|
|
return { valid: false, error: message }
|
|
}
|
|
},
|
|
|
|
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
|
|
const result: Record<string, unknown> = {}
|
|
|
|
if (typeof metadata.title === 'string' && metadata.title.trim()) {
|
|
result.title = metadata.title.trim()
|
|
}
|
|
|
|
if (typeof metadata.owner === 'string' && metadata.owner.trim()) {
|
|
result.owner = metadata.owner.trim()
|
|
}
|
|
|
|
const attendees = joinTagArray(metadata.attendees)
|
|
if (attendees) result.attendees = attendees
|
|
|
|
const folders = joinTagArray(metadata.folders)
|
|
if (folders) result.folders = folders
|
|
|
|
if (typeof metadata.meeting === 'string' && metadata.meeting.trim()) {
|
|
result.meeting = metadata.meeting.trim()
|
|
}
|
|
|
|
const noteDate = parseTagDate(metadata.noteDate)
|
|
if (noteDate) result.noteDate = noteDate
|
|
|
|
const meetingDate = parseTagDate(metadata.meetingDate)
|
|
if (meetingDate) result.meetingDate = meetingDate
|
|
|
|
return result
|
|
},
|
|
}
|