import { writable, get } from 'svelte/store'; import { env } from '$env/dynamic/public'; // Store state: undefined = not yet known, null = failed/logged out, string = token export const accessToken = writable(undefined); export const isSignedIn = writable(false); export const isGoogleApiReady = writable(false); // To track GAPI client readiness export const userEmail = writable(null); let tokenClient: google.accounts.oauth2.TokenClient; let gapiInited = false; let gsiInited = false; // This function ensures both GAPI (for Sheets/Drive APIs) and GSI (for auth) are loaded in the correct order. export function initGoogleClients(callback: () => void) { // If everything is already initialized, just run the callback. if (gapiInited && gsiInited) { isGoogleApiReady.set(true); // Ensure it's set if called again callback(); return; } // 1. Load GAPI script for Sheets/Drive APIs first. if (!gapiInited) { const gapiScript = document.createElement('script'); gapiScript.src = 'https://apis.google.com/js/api.js'; gapiScript.async = true; gapiScript.defer = true; document.head.appendChild(gapiScript); gapiScript.onload = () => { gapi.load('client', () => { gapi.client .init({ discoveryDocs: [ 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest', 'https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest' ] }) .then(() => { gapiInited = true; // Now that GAPI is ready, initialize the GSI client. initGsiClient(callback); }); }); }; } else { // GAPI is already ready, just ensure GSI is initialized. initGsiClient(callback); } } /** * Fetches user's email and stores it. */ async function fetchUserInfo(token: string) { try { const response = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', { headers: { Authorization: `Bearer ${token}` } }); if (!response.ok) { throw new Error('Failed to fetch user info'); } const profile = await response.json(); userEmail.set(profile.email); } catch (error) { console.error('Error fetching user info:', error); userEmail.set(null); } } // 2. Load GSI script for Auth. This should only be called after GAPI is ready. function initGsiClient(callback: () => void) { if (gsiInited) { callback(); return; } const gsiScript = document.createElement('script'); gsiScript.src = 'https://accounts.google.com/gsi/client'; gsiScript.async = true; gsiScript.defer = true; document.head.appendChild(gsiScript); gsiScript.onload = () => { gsiInited = true; tokenClient = google.accounts.oauth2.initTokenClient({ client_id: env.PUBLIC_GOOGLE_CLIENT_ID, scope: 'https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/userinfo.email', callback: (tokenResponse) => { // This callback handles responses from all token requests. if (tokenResponse.error) { console.error('Google token error:', tokenResponse.error); accessToken.set(null); isSignedIn.set(false); if (gapiInited) gapi.client.setToken(null); } else if (tokenResponse.access_token) { const token = tokenResponse.access_token; accessToken.set(token); isSignedIn.set(true); // Also set the token for the GAPI client if (gapiInited) gapi.client.setToken({ access_token: token }); fetchUserInfo(token); } } }); isGoogleApiReady.set(true); callback(); }; } /** * Tries to get a token silently. * This is for background tasks and on-load checks. * It will not show a consent prompt to the user. */ export function ensureToken(): Promise { return new Promise((res, rej) => { initGoogleClients(() => { const currentToken = get(accessToken); // If we already have a valid token, resolve immediately. if (currentToken) { res(currentToken); return; } let unsubscribe: () => void; unsubscribe = accessToken.subscribe((t) => { // undefined means we are still waiting for the initial token request. if (t) { // Got a token. if (unsubscribe) unsubscribe(); res(t); } else if (t === null) { // Got an explicit null, meaning auth failed. if (unsubscribe) unsubscribe(); rej(new Error('Failed to retrieve access token. The user may need to sign in.')); } }); // If no token, request one silently. // The result is handled by the callback in initGsiClient, which updates the store and resolves the promise. if (get(accessToken) === undefined) { tokenClient.requestAccessToken({ prompt: '' }); } }); }); } /** * Prompts the user for consent to grant a token. * This should be called when a user clicks a "Sign In" button. */ export function requestTokenFromUser() { initGoogleClients(() => { if (tokenClient) { tokenClient.requestAccessToken({ prompt: 'consent' }); } else { console.error("requestTokenFromUser called before Google client was initialized."); } }); } /** * Signs the user out, revokes the token, and clears all local state. */ export function handleSignOut() { const token = get(accessToken); if (token && gsiInited) { google.accounts.oauth2.revoke(token, () => { console.log('User token revoked.'); }); } // Clear all tokens and states if (gapiInited) { gapi.client.setToken(null); } accessToken.set(null); isSignedIn.set(false); userEmail.set(null); console.log('User signed out.'); } export async function searchSheets(query: string) { await ensureToken(); // Ensure we are authenticated before making a call if (!gapi.client || !gapi.client.drive) { throw new Error('Google Drive API not loaded'); } const response = await gapi.client.drive.files.list({ q: `mimeType='application/vnd.google-apps.spreadsheet' and name contains '${query}'`, fields: 'files(id, name, iconLink, webViewLink)', pageSize: 20, supportsAllDrives: true, includeItemsFromAllDrives: true, corpora: 'allDrives' }); return response.result.files || []; } export async function getSheetNames(spreadsheetId: string) { await ensureToken(); if (!gapi.client || !gapi.client.sheets) { throw new Error('Google Sheets API not loaded'); } const response = await gapi.client.sheets.spreadsheets.get({ spreadsheetId, fields: 'sheets.properties' }); if (!response.result.sheets) { return []; } return response.result.sheets.map(sheet => sheet.properties?.title || ''); } export async function getSheetData(spreadsheetId: string, range: string) { await ensureToken(); if (!gapi.client || !gapi.client.sheets) { throw new Error('Google Sheets API not loaded'); } const response = await gapi.client.sheets.spreadsheets.values.get({ spreadsheetId, range, }); return response.result.values || []; } // Extract Google Drive file ID from various URL formats export function extractDriveFileId(url: string): string | null { if (!url) return null; // Handle different Google Drive URL formats const patterns = [ /\/file\/d\/([a-zA-Z0-9-_]+)/, // https://drive.google.com/file/d/FILE_ID/view /id=([a-zA-Z0-9-_]+)/, // https://drive.google.com/open?id=FILE_ID /\/d\/([a-zA-Z0-9-_]+)/, // https://drive.google.com/uc?id=FILE_ID&export=download /^([a-zA-Z0-9-_]{25,})$/ // Direct file ID ]; for (const pattern of patterns) { const match = pattern.exec(url); if (match) { return match[1]; } } return null; } // Check if URL is a Google Drive URL export function isGoogleDriveUrl(url: string): boolean { return url.includes('drive.google.com') || url.includes('googleapis.com'); } // Download image from Google Drive using the API export async function downloadDriveImage(url: string): Promise { await ensureToken(); const fileId = extractDriveFileId(url); if (!fileId) { throw new Error('Could not extract file ID from Google Drive URL'); } if (!gapi.client || !gapi.client.drive) { throw new Error('Google Drive API not loaded'); } try { // Get file metadata first to check if it exists and is accessible const metadata = await gapi.client.drive.files.get({ fileId: fileId, fields: 'id,name,mimeType,size' }); if (!metadata.result.mimeType?.startsWith('image/')) { throw new Error('File is not an image'); } // Download the file content const response = await gapi.client.drive.files.get({ fileId: fileId, alt: 'media' }); // The response body is already binary data, convert to blob const binaryString = response.body; const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return new Blob([bytes], { type: metadata.result.mimeType }); } catch (error) { console.error('Error downloading from Google Drive:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to download image from Google Drive: ${errorMessage}`); } } // Create an object URL from image data for display export function createImageObjectUrl(blob: Blob): string { return URL.createObjectURL(blob); }