@@ -11,6 +11,9 @@ const GRANOLA_API_BASE = 'https://public-api.granola.ai/v1'
1111/** Granola caps page_size at 30; request the maximum to minimize round trips. */
1212const PAGE_SIZE = 30
1313
14+ /** Granola folder identifiers match `fol_` followed by 14 alphanumeric chars. */
15+ const FOLDER_ID_PATTERN = / ^ f o l _ [ a - z A - Z 0 - 9 ] { 14 } $ /
16+
1417/**
1518 * A note owner or attendee as returned by the Granola API.
1619 */
@@ -108,6 +111,33 @@ function parseMaxNotes(sourceConfig: Record<string, unknown>): number {
108111 return Number . isFinite ( num ) && num > 0 ? Math . floor ( num ) : 0
109112}
110113
114+ /**
115+ * Parses the optional `folderId` scope from source config. Returns a trimmed
116+ * folder id only when it matches Granola's `fol_…` identifier shape; otherwise
117+ * returns undefined so the request is not scoped to an invalid folder.
118+ */
119+ function parseFolderId ( sourceConfig : Record < string , unknown > ) : string | undefined {
120+ const raw = sourceConfig . folderId
121+ if ( typeof raw !== 'string' ) return undefined
122+ const trimmed = raw . trim ( )
123+ if ( ! trimmed ) return undefined
124+ return FOLDER_ID_PATTERN . test ( trimmed ) ? trimmed : undefined
125+ }
126+
127+ /**
128+ * Parses the optional `createdAfter` date filter from source config. Returns a
129+ * normalized ISO 8601 string when the value is a valid date; otherwise returns
130+ * undefined so the request is not scoped to an invalid date.
131+ */
132+ function parseCreatedAfter ( sourceConfig : Record < string , unknown > ) : string | undefined {
133+ const raw = sourceConfig . createdAfter
134+ if ( typeof raw !== 'string' ) return undefined
135+ const trimmed = raw . trim ( )
136+ if ( ! trimmed ) return undefined
137+ const parsed = new Date ( trimmed )
138+ return Number . isNaN ( parsed . getTime ( ) ) ? undefined : parsed . toISOString ( )
139+ }
140+
111141/**
112142 * Detects whether a string contains HTML markup. Granola returns markdown for
113143 * `summary_markdown`, but this guard lets us defensively strip tags if the API
@@ -212,6 +242,26 @@ export const granolaConnector: ConnectorConfig = {
212242 placeholder : 'e.g. 200 (default: unlimited)' ,
213243 description : 'Cap the number of notes synced. Leave blank to sync all notes.' ,
214244 } ,
245+ {
246+ id : 'folderId' ,
247+ title : 'Folder ID' ,
248+ type : 'short-input' ,
249+ required : false ,
250+ mode : 'advanced' ,
251+ placeholder : 'e.g. fol_4y6LduVdwSKC27' ,
252+ description :
253+ 'Scope the sync to a single folder and its child folders. Leave blank to sync notes from all folders.' ,
254+ } ,
255+ {
256+ id : 'createdAfter' ,
257+ title : 'Created After' ,
258+ type : 'short-input' ,
259+ required : false ,
260+ mode : 'advanced' ,
261+ placeholder : 'e.g. 2025-01-01 or 2025-01-01T00:00:00Z' ,
262+ description :
263+ 'Only sync notes created on or after this date (ISO 8601). Leave blank to sync notes regardless of creation date.' ,
264+ } ,
215265 ] ,
216266
217267 listDocuments : async (
@@ -222,15 +272,21 @@ export const granolaConnector: ConnectorConfig = {
222272 lastSyncAt ?: Date
223273 ) : Promise < ExternalDocumentList > => {
224274 const maxNotes = parseMaxNotes ( sourceConfig )
275+ const folderId = parseFolderId ( sourceConfig )
276+ const createdAfter = parseCreatedAfter ( sourceConfig )
225277
226278 const url = new URL ( `${ GRANOLA_API_BASE } /notes` )
227279 url . searchParams . set ( 'page_size' , String ( PAGE_SIZE ) )
228280 if ( cursor ) url . searchParams . set ( 'cursor' , cursor )
229281 if ( lastSyncAt ) url . searchParams . set ( 'updated_after' , lastSyncAt . toISOString ( ) )
282+ if ( folderId ) url . searchParams . set ( 'folder_id' , folderId )
283+ if ( createdAfter ) url . searchParams . set ( 'created_after' , createdAfter )
230284
231285 logger . info ( 'Listing Granola notes' , {
232286 hasCursor : Boolean ( cursor ) ,
233287 incremental : Boolean ( lastSyncAt ) ,
288+ scopedToFolder : Boolean ( folderId ) ,
289+ scopedByCreatedAfter : Boolean ( createdAfter ) ,
234290 } )
235291
236292 const response = await fetchWithRetry ( url . toString ( ) , {
@@ -350,6 +406,32 @@ export const granolaConnector: ConnectorConfig = {
350406 return { valid : false , error : 'Max notes must be a non-negative number' }
351407 }
352408
409+ const folderId = sourceConfig . folderId
410+ if (
411+ typeof folderId === 'string' &&
412+ folderId . trim ( ) &&
413+ ! FOLDER_ID_PATTERN . test ( folderId . trim ( ) )
414+ ) {
415+ return {
416+ valid : false ,
417+ error :
418+ 'Folder ID must look like fol_ followed by 14 alphanumeric characters (e.g. fol_4y6LduVdwSKC27)' ,
419+ }
420+ }
421+
422+ const createdAfter = sourceConfig . createdAfter
423+ if (
424+ typeof createdAfter === 'string' &&
425+ createdAfter . trim ( ) &&
426+ Number . isNaN ( new Date ( createdAfter . trim ( ) ) . getTime ( ) )
427+ ) {
428+ return {
429+ valid : false ,
430+ error :
431+ 'Created After must be a valid date (ISO 8601, e.g. 2025-01-01 or 2025-01-01T00:00:00Z)' ,
432+ }
433+ }
434+
353435 try {
354436 const url = new URL ( `${ GRANOLA_API_BASE } /notes` )
355437 url . searchParams . set ( 'page_size' , '1' )
0 commit comments