diff --git a/surfsense_web/hooks/use-google-picker.ts b/surfsense_web/hooks/use-google-picker.ts new file mode 100644 index 000000000..45e696235 --- /dev/null +++ b/surfsense_web/hooks/use-google-picker.ts @@ -0,0 +1,151 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { connectorsApiService } from "@/lib/apis/connectors-api.service"; + +export interface PickerItem { + id: string; + name: string; + mimeType: string; +} + +export interface PickerResult { + folders: PickerItem[]; + files: PickerItem[]; +} + +interface UseGooglePickerOptions { + connectorId: number; + onPicked: (result: PickerResult) => void; +} + +const PICKER_SCRIPT_URL = "https://apis.google.com/js/api.js"; +const FOLDER_MIME = "application/vnd.google-apps.folder"; + +let scriptLoadPromise: Promise | null = null; +let pickerApiPromise: Promise | null = null; + +function loadPickerScript(): Promise { + if (scriptLoadPromise) return scriptLoadPromise; + if (typeof window !== "undefined" && window.gapi) { + scriptLoadPromise = Promise.resolve(); + return scriptLoadPromise; + } + + scriptLoadPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = PICKER_SCRIPT_URL; + script.async = true; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => { + scriptLoadPromise = null; + reject(new Error("Failed to load Google Picker script")); + }; + document.head.appendChild(script); + }); + return scriptLoadPromise; +} + +function loadPickerApi(): Promise { + if (pickerApiPromise) return pickerApiPromise; + + pickerApiPromise = new Promise((resolve, reject) => { + gapi.load("picker", { + callback: () => resolve(), + onerror: () => { + pickerApiPromise = null; + reject(new Error("Failed to load Google Picker API")); + }, + }); + }); + return pickerApiPromise; +} + +export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOptions) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const onPickedRef = useRef(onPicked); + onPickedRef.current = onPicked; + const openingRef = useRef(false); + + const openPicker = useCallback(async () => { + if (openingRef.current) return; + openingRef.current = true; + setLoading(true); + setError(null); + + try { + const [tokenData] = await Promise.all([ + connectorsApiService.getDrivePickerToken(connectorId), + loadPickerScript().then(() => loadPickerApi()), + ]); + + const { access_token, picker_api_key } = tokenData; + + const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS) + .setIncludeFolders(true) + .setSelectFolderEnabled(true); + + let pickerInstance: google.picker.Picker | null = null; + + const picker = new google.picker.PickerBuilder() + .addView(docsView) + .enableFeature(google.picker.Feature.MULTISELECT_ENABLED) + .setOAuthToken(access_token) + .setDeveloperKey(picker_api_key) + .setOrigin(window.location.protocol + "//" + window.location.host) + .setTitle("Select files and folders to index") + .setCallback((data: google.picker.ResponseObject) => { + const action = data[google.picker.Response.ACTION]; + + if (action === google.picker.Action.PICKED) { + const docs = data[google.picker.Response.DOCUMENTS]; + if (docs) { + const folders: PickerItem[] = []; + const files: PickerItem[] = []; + + for (const doc of docs) { + const mimeType = doc[google.picker.Document.MIME_TYPE] ?? ""; + const item: PickerItem = { + id: doc[google.picker.Document.ID], + name: doc[google.picker.Document.NAME] ?? "Untitled", + mimeType, + }; + if (mimeType === FOLDER_MIME) { + folders.push(item); + } else { + files.push(item); + } + } + + onPickedRef.current({ folders, files }); + } + } + + if ( + action === google.picker.Action.PICKED || + action === google.picker.Action.CANCEL || + action === google.picker.Action.ERROR + ) { + pickerInstance?.dispose(); + pickerInstance = null; + openingRef.current = false; + } + }) + .build(); + + pickerInstance = picker; + picker.setVisible(true); + } catch (err) { + openingRef.current = false; + const msg = err instanceof Error ? err.message : "Failed to open Google Picker"; + setError(msg); + console.error("Google Picker error:", err); + } finally { + setLoading(false); + } + }, [connectorId]); + + return { openPicker, loading, error }; +} diff --git a/surfsense_web/lib/apis/connectors-api.service.ts b/surfsense_web/lib/apis/connectors-api.service.ts index 4a6d67d80..ba607ccc1 100644 --- a/surfsense_web/lib/apis/connectors-api.service.ts +++ b/surfsense_web/lib/apis/connectors-api.service.ts @@ -266,6 +266,17 @@ class ConnectorsApiService { ); }; + /** + * Get Google Picker token (access_token + client_id + picker_api_key) for a Drive connector + */ + getDrivePickerToken = async (connectorId: number) => { + return baseApiService.get<{ + access_token: string; + client_id: string; + picker_api_key: string; + }>(`/api/v1/connectors/${connectorId}/drive-picker-token`); + }; + // ============================================================================= // MCP Connector Methods // ============================================================================= diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 0789e304e..89c1757a1 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -144,6 +144,8 @@ "@tailwindcss/postcss": "^4.1.11", "@tailwindcss/typography": "^0.5.16", "@types/canvas-confetti": "^1.9.0", + "@types/gapi": "^0.0.47", + "@types/google.picker": "^0.0.52", "@types/node": "^20.19.9", "@types/pg": "^8.15.5", "@types/react": "^19.1.8", diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index b78cc44c8..5dda2b6cb 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -372,6 +372,12 @@ importers: '@types/canvas-confetti': specifier: ^1.9.0 version: 1.9.0 + '@types/gapi': + specifier: ^0.0.47 + version: 0.0.47 + '@types/google.picker': + specifier: ^0.0.52 + version: 0.0.52 '@types/node': specifier: ^20.19.9 version: 20.19.33 @@ -3807,6 +3813,12 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/gapi@0.0.47': + resolution: {integrity: sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==} + + '@types/google.picker@0.0.52': + resolution: {integrity: sha512-k0HyW8HxJePomM2r0JWq9nE9XG6qY93lVpoVnaV4WjQggDHrGwDKq3G8CGpcBWhQlJBTxX9jDIrI7RQnqjM63w==} + '@types/hast@2.3.10': resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} @@ -10486,6 +10498,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/gapi@0.0.47': {} + + '@types/google.picker@0.0.52': {} + '@types/hast@2.3.10': dependencies: '@types/unist': 2.0.11