Files
card-forge/src/lib/google.ts
Roman Krček 68e4d0b77b
All checks were successful
Build Docker image / build (push) Successful in 8m8s
Build Docker image / deploy (push) Successful in 4s
Build Docker image / verify (push) Successful in 30s
Add loader for google services
2025-09-08 16:04:50 +02:00

306 lines
9.2 KiB
TypeScript

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<string | null | undefined>(undefined);
export const isSignedIn = writable(false);
export const isGoogleApiReady = writable(false); // To track GAPI client readiness
export const userEmail = writable<string | null>(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<string> {
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<Blob> {
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);
}