diff --git a/tools/google-drive/src/google-api.ts b/tools/google-drive/src/google-api.ts index 913bfc9..adb9de9 100644 --- a/tools/google-drive/src/google-api.ts +++ b/tools/google-drive/src/google-api.ts @@ -73,9 +73,10 @@ export class GoogleApi { } } - const query = Object.keys(filteredParams).length > 0 - ? `?${new URLSearchParams(filteredParams)}` - : ""; + const query = + Object.keys(filteredParams).length > 0 + ? `?${new URLSearchParams(filteredParams)}` + : ""; const headers = { Authorization: `Bearer ${this.accessToken}`, Accept: "application/json", @@ -109,7 +110,7 @@ export class GoogleApi { const DRIVE_API = "https://www.googleapis.com/drive/v3"; /** - * List folders accessible by the user. + * List folders accessible by the user, including those in shared drives. */ export async function listFolders(api: GoogleApi): Promise { const folders: GoogleDriveFile[] = []; @@ -118,6 +119,9 @@ export async function listFolders(api: GoogleApi): Promise { do { const data = (await api.call("GET", `${DRIVE_API}/files`, { q: "mimeType='application/vnd.google-apps.folder' and trashed=false", + corpora: "allDrives", + includeItemsFromAllDrives: true, + supportsAllDrives: true, fields: "nextPageToken,files(id,name,description,parents)", pageSize: 100, pageToken, @@ -131,6 +135,32 @@ export async function listFolders(api: GoogleApi): Promise { return folders; } +export type SharedDrive = { + id: string; + name: string; +}; + +/** + * List shared drives accessible by the user. + */ +export async function listSharedDrives(api: GoogleApi): Promise { + const drives: SharedDrive[] = []; + let pageToken: string | undefined; + + do { + const data = (await api.call("GET", `${DRIVE_API}/drives`, { + pageSize: 100, + pageToken, + })) as { drives?: SharedDrive[]; nextPageToken?: string } | null; + + if (!data) break; + drives.push(...(data.drives || [])); + pageToken = data.nextPageToken; + } while (pageToken); + + return drives; +} + /** * List files in a folder (non-folder items only). */ @@ -141,6 +171,8 @@ export async function listFilesInFolder( ): Promise<{ files: GoogleDriveFile[]; nextPageToken?: string }> { const data = (await api.call("GET", `${DRIVE_API}/files`, { q: `'${folderId}' in parents and mimeType!='application/vnd.google-apps.folder' and trashed=false`, + includeItemsFromAllDrives: true, + supportsAllDrives: true, fields: "nextPageToken,files(id,name,mimeType,description,webViewLink,iconLink,createdTime,modifiedTime,owners,permissions(emailAddress,displayName),parents)", pageSize: 50, @@ -217,13 +249,10 @@ export async function createReply( /** * Get the changes start page token for incremental sync. */ -export async function getChangesStartToken( - api: GoogleApi -): Promise { - const data = (await api.call( - "GET", - `${DRIVE_API}/changes/startPageToken` - )) as { startPageToken: string }; +export async function getChangesStartToken(api: GoogleApi): Promise { + const data = (await api.call("GET", `${DRIVE_API}/changes/startPageToken`, { + supportsAllDrives: true, + })) as { startPageToken: string }; return data.startPageToken; } @@ -244,6 +273,8 @@ export async function listChanges( }> { const data = (await api.call("GET", `${DRIVE_API}/changes`, { pageToken, + includeItemsFromAllDrives: true, + supportsAllDrives: true, fields: "nextPageToken,newStartPageToken,changes(fileId,removed,file(id,name,mimeType,description,webViewLink,iconLink,createdTime,modifiedTime,owners,permissions(emailAddress,displayName),parents))", pageSize: 100, diff --git a/tools/google-drive/src/google-drive.ts b/tools/google-drive/src/google-drive.ts index 201fb17..9c2118f 100644 --- a/tools/google-drive/src/google-drive.ts +++ b/tools/google-drive/src/google-drive.ts @@ -42,6 +42,7 @@ import { listComments, listFilesInFolder, listFolders, + listSharedDrives, } from "./google-api"; /** @@ -96,15 +97,72 @@ export class GoogleDrive extends Tool implements DocumentTool { } /** - * Returns available Google Drive folders as syncable resources. + * Returns available Google Drive folders as a syncable tree. + * Shared drives and root-level My Drive folders appear at the top level, + * with subfolders nested under their parents. */ async getSyncables( _auth: Authorization, token: AuthToken ): Promise { const api = new GoogleApi(token.token); - const files = await listFolders(api); - return files.map((f) => ({ id: f.id, title: f.name })); + const [folders, sharedDrives] = await Promise.all([ + listFolders(api), + listSharedDrives(api), + ]); + + // Build node map for all folders + type SyncableNode = { id: string; title: string; children: SyncableNode[] }; + const nodeMap = new Map(); + for (const f of folders) { + nodeMap.set(f.id, { id: f.id, title: f.name, children: [] }); + } + + // Build shared drive node map + const sharedDriveMap = new Map(); + for (const drive of sharedDrives) { + sharedDriveMap.set(drive.id, { + id: drive.id, + title: drive.name, + children: [], + }); + } + + // Link children to parents + const roots: SyncableNode[] = []; + for (const f of folders) { + const node = nodeMap.get(f.id)!; + const parentId = f.parents?.[0]; + if (parentId) { + const parentFolder = nodeMap.get(parentId); + if (parentFolder) { + parentFolder.children.push(node); + continue; + } + const parentDrive = sharedDriveMap.get(parentId); + if (parentDrive) { + parentDrive.children.push(node); + continue; + } + } + // No known parent in our set → root folder (My Drive) + roots.push(node); + } + + // Combine: shared drives first, then root My Drive folders + const allRoots = [...sharedDriveMap.values(), ...roots]; + + // Strip empty children arrays for clean output + const clean = (nodes: SyncableNode[]): Syncable[] => { + return nodes.map((n) => { + if (n.children.length > 0) { + return { id: n.id, title: n.title, children: clean(n.children) }; + } + return { id: n.id, title: n.title }; + }); + }; + + return clean(allRoots); } /** @@ -201,13 +259,21 @@ export class GoogleDrive extends Tool implements DocumentTool { async getFolders(folderId: string): Promise { const api = await this.getApi(folderId); - const files = await listFolders(api); + const [files, drives] = await Promise.all([ + listFolders(api), + listSharedDrives(api), + ]); + + const driveIds = new Set(drives.map((d) => d.id)); return files.map((file) => ({ id: file.id, name: file.name, description: file.description || null, - root: !file.parents || file.parents.length === 0, + root: + !file.parents || + file.parents.length === 0 || + file.parents.every((p) => driveIds.has(p)), })); } @@ -347,7 +413,7 @@ export class GoogleDrive extends Tool implements DocumentTool { const watchData = (await api.call( "POST", "https://www.googleapis.com/drive/v3/changes/watch", - { pageToken: changesToken }, + { pageToken: changesToken, supportsAllDrives: true }, { id: watchId, type: "web_hook", diff --git a/twister/src/tools/integrations.ts b/twister/src/tools/integrations.ts index 9fefebd..0993624 100644 --- a/twister/src/tools/integrations.ts +++ b/twister/src/tools/integrations.ts @@ -10,6 +10,8 @@ export type Syncable = { id: string; /** Display name shown in the UI */ title: string; + /** Optional nested syncable resources (e.g., subfolders) */ + children?: Syncable[]; }; /**