From 9730862858ea0c6597ccc3fe073cd930426def22 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Mon, 24 Nov 2025 00:02:48 +0900 Subject: [PATCH] feat: add Google Drive file loading functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ability to load Scratch 3.0 projects (.sb3) directly from Google Drive. Key features: - Dynamic loading of Google API scripts (improves initial page load) - OAuth 2.0 authentication with Google Identity Services - Google Picker API integration for file selection - Seamless project loading from Google Drive Implementation details: - Created dynamic script loader (src/lib/google-script-loader.js) to load Google APIs on demand - Implemented Google Drive API module (src/lib/google-drive-api.js) for authentication and file operations - Added Google Drive loader HOC container (src/containers/google-drive-loader.jsx) - Added "Load from Google Drive" menu item to File menu - Created comprehensive setup documentation (docs/google-drive-setup.md) - Added environment variable configuration (.env.example) - Updated .gitignore to exclude .env files - Added cspell.json for custom dictionary words Configuration: - Requires GOOGLE_CLIENT_ID and GOOGLE_API_KEY environment variables - See docs/google-drive-setup.md for detailed GCP setup instructions Closes #426 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 10 ++ .gitignore | 3 + cspell.json | 15 ++ docs/google-drive-setup.md | 166 +++++++++++++++++ src/components/menu-bar/menu-bar.jsx | 12 ++ src/containers/google-drive-loader.jsx | 214 ++++++++++++++++++++++ src/lib/google-drive-api.js | 238 +++++++++++++++++++++++++ src/lib/google-script-loader.js | 94 ++++++++++ 8 files changed, 752 insertions(+) create mode 100644 .env.example create mode 100644 cspell.json create mode 100644 docs/google-drive-setup.md create mode 100644 src/containers/google-drive-loader.jsx create mode 100644 src/lib/google-drive-api.js create mode 100644 src/lib/google-script-loader.js diff --git a/.env.example b/.env.example new file mode 100644 index 00000000000..210f4cf4bab --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Google Drive API Configuration +# See docs/google-drive-setup.md for setup instructions + +# Google OAuth 2.0 Client ID (Web Application) +# Example: 123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com +GOOGLE_CLIENT_ID=your-client-id-here.apps.googleusercontent.com + +# Google API Key (for Picker API) +# Example: AIzaSyAbCdEfGhIjKlMnOpQrStUvWxYz1234567 +GOOGLE_API_KEY=your-api-key-here diff --git a/.gitignore b/.gitignore index 9f18dfb39a4..b5dec787d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,7 @@ npm-* # for act .secrets +# Environment variables (contains sensitive API keys) +.env + /tmp diff --git a/cspell.json b/cspell.json new file mode 100644 index 00000000000..e1782e61c54 --- /dev/null +++ b/cspell.json @@ -0,0 +1,15 @@ +{ + "version": "0.2", + "language": "en", + "words": [ + "gapi", + "googleusercontent" + ], + "ignorePaths": [ + "node_modules", + "build", + "dist", + "coverage", + ".nyc_output" + ] +} diff --git a/docs/google-drive-setup.md b/docs/google-drive-setup.md new file mode 100644 index 00000000000..679843e7e17 --- /dev/null +++ b/docs/google-drive-setup.md @@ -0,0 +1,166 @@ +# Google Drive連携機能のセットアップ手順 + +このドキュメントでは、Smalruby3-GUIでGoogle Driveからファイルを読み込むための機能を有効にするために必要なGoogle Cloud Platform (GCP)の設定手順を説明します。 + +## 前提条件 + +- Googleアカウントを持っていること +- Google Cloud Platformへのアクセス権限があること + +## 1. Google Cloud Platformプロジェクトの作成 + +1. [Google Cloud Console](https://console.cloud.google.com/)にアクセス +2. 画面上部の「プロジェクトを選択」をクリック +3. 「新しいプロジェクト」をクリック +4. プロジェクト名を入力(例: `smalruby3-gui`) +5. 「作成」をクリック + +## 2. 必要なAPIの有効化 + +### Google Drive APIを有効化 + +1. Google Cloud Consoleで作成したプロジェクトを選択 +2. 左側のメニューから「APIとサービス」→「ライブラリ」を選択 +3. 検索ボックスに「Google Drive API」と入力 +4. 「Google Drive API」を選択 +5. 「有効にする」をクリック + +### Google Picker APIを有効化 + +1. 同じく「ライブラリ」画面で検索ボックスに「Google Picker API」と入力 +2. 「Google Picker API」を選択 +3. 「有効にする」をクリック + +## 3. OAuth 2.0 認証情報の作成 + +### OAuth同意画面の設定 + +1. 左側のメニューから「APIとサービス」→「OAuth同意画面」を選択 +2. ユーザータイプで「外部」を選択(テスト用途の場合) +3. 「作成」をクリック +4. アプリ情報を入力: + - **アプリ名**: `Smalruby3 GUI` + - **ユーザーサポートメール**: あなたのメールアドレス + - **デベロッパーの連絡先情報**: あなたのメールアドレス +5. 「保存して次へ」をクリック +6. 「スコープ」画面で「スコープを追加または削除」をクリック +7. 以下のスコープを追加: + - `https://www.googleapis.com/auth/drive.readonly` + または + - `https://www.googleapis.com/auth/drive.file` +8. 「更新」→「保存して次へ」をクリック +9. テストユーザー画面で「保存して次へ」をクリック +10. 確認画面で「ダッシュボードに戻る」をクリック + +### OAuth 2.0 クライアントIDの作成 + +1. 左側のメニューから「APIとサービス」→「認証情報」を選択 +2. 画面上部の「認証情報を作成」→「OAuth クライアント ID」をクリック +3. アプリケーションの種類で「ウェブアプリケーション」を選択 +4. 名前を入力(例: `Smalruby3 GUI Web Client`) +5. 「承認済みのJavaScript生成元」に以下を追加: + - `http://localhost:8601` (開発環境) + - 本番環境のURL(例: `https://smalruby.github.io`) +6. 「承認済みのリダイレクトURI」は空欄でOK(Picker APIでは不要) +7. 「作成」をクリック +8. 表示されたダイアログで「クライアントID」をコピーして保存 + +**重要**: クライアントシークレットは今回の実装では使用しません(フロントエンドのみの実装のため) + +## 4. APIキーの作成(Picker API用) + +1. 「認証情報」画面で「認証情報を作成」→「APIキー」をクリック +2. APIキーが作成されるので、コピーして保存 +3. (推奨)「キーを制限」をクリック +4. 「アプリケーションの制限」で「HTTPリファラー(ウェブサイト)」を選択 +5. 「ウェブサイトの制限」に以下を追加: + - `http://localhost:8601/*` + - 本番環境のURL(例: `https://smalruby.github.io/*`) +6. 「APIの制限」で「キーを制限」を選択 +7. 「Google Picker API」を選択 +8. 「保存」をクリック + +## 5. 環境変数の設定 + +### 開発環境 + +プロジェクトルートに `.env` ファイルを作成し、以下の内容を記述: + +```bash +# Google Drive API設定 +GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com +GOOGLE_API_KEY=your-api-key +``` + +**注意**: `.env` ファイルは `.gitignore` に含まれているため、Gitにコミットされません。 + +### 本番環境 + +本番環境では、ビルド時に環境変数を設定します: + +```bash +GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com \ +GOOGLE_API_KEY=your-api-key \ +npm run build +``` + +または、GitHub ActionsなどのCI/CD環境では、シークレット変数として設定します。 + +## 6. セキュリティに関する注意事項 + +### クライアントIDとAPIキーの管理 + +- **クライアントID**: 公開しても問題ありませんが、承認済みのJavaScript生成元で制限することを推奨 +- **APIキー**: HTTPリファラーで制限し、API制限を有効にすることを強く推奨 +- **クライアントシークレット**: フロントエンドでは使用しないこと(セキュリティリスク) + +### OAuth 2.0 スコープの選択 + +- `drive.readonly`: ファイルの読み取り専用(推奨) +- `drive.file`: アプリが作成したファイルの読み書き + +読み取り専用の用途であれば `drive.readonly` を使用してください。 + +## 7. 動作確認 + +1. 開発サーバーを起動: + ```bash + docker compose up gui + ``` + +2. ブラウザで `http://localhost:8601` を開く + +3. ファイルメニューから「Google ドライブから読み込む」を選択 + +4. Google認証画面が表示されることを確認 + +5. 認証後、Google Driveのファイル選択画面が表示されることを確認 + +## トラブルシューティング + +### "Access blocked: This app's request is invalid" + +- OAuth同意画面の設定が完了していない可能性があります +- テストユーザーに自分のアカウントが追加されているか確認してください + +### "API key not valid. Please pass a valid API key." + +- APIキーが正しく設定されていない可能性があります +- APIキーの制限設定を確認してください +- Picker APIが有効になっているか確認してください + +### "The origin http://localhost:8601 is not allowed" + +- OAuth クライアントIDの「承認済みのJavaScript生成元」に `http://localhost:8601` が追加されているか確認してください + +### 動的スクリプトのロードエラー + +- ブラウザのコンソールでエラー内容を確認してください +- Content Security Policy (CSP) の設定を確認してください + +## 参考リンク + +- [Google Cloud Console](https://console.cloud.google.com/) +- [Google Picker API Documentation](https://developers.google.com/picker/api) +- [Google Identity Services](https://developers.google.com/identity/gsi/web/guides/overview) +- [Google Drive API v3](https://developers.google.com/drive/api/v3/about-sdk) diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 47b9926b3f5..4a982a0512b 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -27,6 +27,7 @@ import SB3Downloader from '../../containers/sb3-downloader.jsx'; import DeletionRestorer from '../../containers/deletion-restorer.jsx'; import TurboMode from '../../containers/turbo-mode.jsx'; import MenuBarHOC from '../../containers/menu-bar-hoc.jsx'; +import GoogleDriveLoaderHOC from '../../containers/google-drive-loader.jsx'; import SettingsMenu from './settings-menu.jsx'; import {openTipsLibrary, openDebugModal} from '../../reducers/modals'; @@ -519,6 +520,15 @@ class MenuBar extends React.Component { id="gui.menuBar.loadFromUrl" /> + + + @@ -946,6 +956,7 @@ MenuBar.propTypes = { onSetTimeTravelMode: PropTypes.func, onShare: PropTypes.func, onStartSelectingFileUpload: PropTypes.func, + onStartSelectingGoogleDrive: PropTypes.func, onStartSelectingUrlLoad: PropTypes.func, onToggleLoginOpen: PropTypes.func, projectTitle: PropTypes.string, @@ -1026,6 +1037,7 @@ const mapDispatchToProps = dispatch => ({ export default compose( injectIntl, MenuBarHOC, + GoogleDriveLoaderHOC, connect( mapStateToProps, mapDispatchToProps diff --git a/src/containers/google-drive-loader.jsx b/src/containers/google-drive-loader.jsx new file mode 100644 index 00000000000..d7c0fd8c6f5 --- /dev/null +++ b/src/containers/google-drive-loader.jsx @@ -0,0 +1,214 @@ +import bindAll from 'lodash.bindall'; +import React from 'react'; +import PropTypes from 'prop-types'; +import {defineMessages, intlShape, injectIntl} from 'react-intl'; +import {connect} from 'react-redux'; +import log from '../lib/log'; + +import googleDriveAPI from '../lib/google-drive-api'; +import { + LoadingStates, + getIsLoadingUpload, + onLoadedProject, + requestProjectUpload +} from '../reducers/project-state'; +import {setProjectTitle} from '../reducers/project-title'; +import { + openLoadingProject, + closeLoadingProject +} from '../reducers/modals'; + +const messages = defineMessages({ + loadError: { + id: 'gui.googleDriveLoader.loadError', + defaultMessage: 'Failed to load project from Google Drive.', + description: 'An error that displays when a Google Drive project file fails to load.' + }, + authError: { + id: 'gui.googleDriveLoader.authError', + defaultMessage: 'Failed to authenticate with Google Drive. Please try again.', + description: 'An error that displays when Google Drive authentication fails.' + }, + configError: { + id: 'gui.googleDriveLoader.configError', + defaultMessage: 'Google Drive is not configured. Please contact the administrator.', + description: 'An error that displays when Google Drive API is not configured.' + } +}); + +/** + * Higher Order Component to provide behavior for loading projects from Google Drive. + * @param {React.Component} WrappedComponent the component to add Google Drive loading functionality to + * @returns {React.Component} WrappedComponent with Google Drive loading functionality added + * + * + * + * + */ +const GoogleDriveLoaderHOC = function (WrappedComponent) { + class GoogleDriveLoaderComponent extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleStartSelectingGoogleDrive', + 'handlePickerCallback', + 'handleFinishedLoadingUpload', + 'getProjectTitleFromFilename' + ]); + } + + componentDidUpdate (prevProps) { + if (this.props.isLoadingUpload && !prevProps.isLoadingUpload) { + this.handleFinishedLoadingUpload(); + } + } + + /** + * Start Google Drive file selection process + */ + handleStartSelectingGoogleDrive () { + // Check if Google Drive is configured + if (!googleDriveAPI.constructor.isConfigured()) { + alert(this.props.intl.formatMessage(messages.configError)); // eslint-disable-line no-alert + log.warn('Google Drive API is not configured'); + return; + } + + // Show loading modal while initializing + this.props.onShowLoadingProject(); + + // Initialize and show Google Picker + googleDriveAPI.showPicker(this.handlePickerCallback) + .catch(error => { + log.error('Failed to show Google Picker:', error); + this.props.onCloseLoadingProject(); + alert(this.props.intl.formatMessage(messages.authError)); // eslint-disable-line no-alert + }); + } + + /** + * Handle Google Picker callback + * @param {object} result - Picker result + */ + handlePickerCallback (result) { + if (result.cancelled) { + // User cancelled picker + this.props.onCloseLoadingProject(); + return; + } + + if (result.error) { + // Error occurred + log.error('Google Drive picker error:', result.error); + this.props.onCloseLoadingProject(); + alert(result.error); // eslint-disable-line no-alert + return; + } + + if (result.success) { + // File selected and downloaded successfully + const {fileName, fileData} = result; + + // Update project title + const projectTitle = this.getProjectTitleFromFilename(fileName); + this.props.onSetProjectTitle(projectTitle); + + // Convert ArrayBuffer to Uint8Array + const content = new Uint8Array(fileData); + + // Load the project + this.props.onLoadingStarted(); + this.props.vm.loadProject(content) + .then(() => { + this.props.onLoadingFinished(this.props.loadingState, true); + this.props.onCloseLoadingProject(); + }) + .catch(error => { + log.error('Failed to load project from Google Drive:', error); + this.props.onCloseLoadingProject(); + alert(this.props.intl.formatMessage(messages.loadError)); // eslint-disable-line no-alert + }); + } + } + + /** + * Extract project title from filename + * @param {string} filename - File name + * @returns {string} Project title + */ + getProjectTitleFromFilename (filename) { + return filename.replace(/\.sb3$/, ''); + } + + /** + * Handle finished loading upload + */ + handleFinishedLoadingUpload () { + this.props.onLoadingFinished(this.props.loadingState, true); + } + + render () { + const { + /* eslint-disable no-unused-vars */ + intl, + isLoadingUpload, + loadingState, + onCloseLoadingProject, + onLoadingFinished, + onLoadingStarted, + onSetProjectTitle, + onShowLoadingProject, + vm, + /* eslint-enable no-unused-vars */ + ...componentProps + } = this.props; + + return ( + + ); + } + } + + GoogleDriveLoaderComponent.propTypes = { + intl: intlShape.isRequired, + isLoadingUpload: PropTypes.bool, + loadingState: PropTypes.oneOf(LoadingStates), + onCloseLoadingProject: PropTypes.func, + onLoadingFinished: PropTypes.func, + onLoadingStarted: PropTypes.func, + onSetProjectTitle: PropTypes.func, + onShowLoadingProject: PropTypes.func, + vm: PropTypes.shape({ + loadProject: PropTypes.func + }) + }; + + const mapStateToProps = state => ({ + isLoadingUpload: getIsLoadingUpload(state.scratchGui.projectState.loadingState), + loadingState: state.scratchGui.projectState.loadingState, + vm: state.scratchGui.vm + }); + + const mapDispatchToProps = dispatch => ({ + onCloseLoadingProject: () => dispatch(closeLoadingProject()), + onLoadingFinished: (loadingState, success) => { + dispatch(onLoadedProject(loadingState, false, success)); + dispatch(closeLoadingProject()); + }, + onLoadingStarted: () => dispatch(requestProjectUpload(LoadingStates.LOADING_VM_FILE_UPLOAD)), + onSetProjectTitle: title => dispatch(setProjectTitle(title)), + onShowLoadingProject: () => dispatch(openLoadingProject()) + }); + + return injectIntl(connect( + mapStateToProps, + mapDispatchToProps + )(GoogleDriveLoaderComponent)); +}; + +export { + GoogleDriveLoaderHOC as default +}; diff --git a/src/lib/google-drive-api.js b/src/lib/google-drive-api.js new file mode 100644 index 00000000000..75ae1d8f6e6 --- /dev/null +++ b/src/lib/google-drive-api.js @@ -0,0 +1,238 @@ +/** + * Google Drive API Integration + * + * This module handles Google Drive authentication and file picking using: + * - Google Identity Services for OAuth 2.0 authentication + * - Google Picker API for file selection UI + */ + +import {loadAllGoogleScripts} from './google-script-loader'; + +// OAuth 2.0 scopes +const SCOPES = 'https://www.googleapis.com/auth/drive.readonly'; + +// Discovery docs for Google Drive API +const DISCOVERY_DOCS = ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest']; + +// Google API configuration (loaded from environment variables) +const CLIENT_ID = process.env.GOOGLE_CLIENT_ID; +const API_KEY = process.env.GOOGLE_API_KEY; + +/** + * GoogleDriveAPI class + * Manages authentication and file operations with Google Drive + */ +class GoogleDriveAPI { + constructor () { + this.isInitialized = false; + this.tokenClient = null; + this.accessToken = null; + this.pickerCallback = null; + } + + /** + * Initialize Google API and Identity Services + * @returns {Promise} Promise that resolves when initialization is complete + */ + async initialize () { + if (this.isInitialized) { + return; + } + + // Validate configuration + if (!CLIENT_ID || !API_KEY) { + throw new Error( + 'Google Drive API credentials not configured. ' + + 'Please set GOOGLE_CLIENT_ID and GOOGLE_API_KEY environment variables. ' + + 'See docs/google-drive-setup.md for setup instructions.' + ); + } + + try { + // Load Google scripts dynamically + await loadAllGoogleScripts(); + + // Initialize gapi client + await new Promise((resolve, reject) => { + window.gapi.load('client:picker', { + callback: resolve, + onerror: reject + }); + }); + + // Initialize gapi client with API key and discovery docs + await window.gapi.client.init({ + apiKey: API_KEY, + discoveryDocs: DISCOVERY_DOCS + }); + + // Initialize Google Identity Services token client + this.tokenClient = window.google.accounts.oauth2.initTokenClient({ + client_id: CLIENT_ID, + scope: SCOPES, + callback: '' // Will be set dynamically when requesting access + }); + + this.isInitialized = true; + } catch (error) { + console.error('Failed to initialize Google Drive API:', error); + throw new Error('Google Drive API initialization failed. Please check your configuration.'); + } + } + + /** + * Request access token + * @returns {Promise} Promise that resolves with access token + */ + requestAccessToken () { + return new Promise((resolve, reject) => { + this.tokenClient.callback = response => { + if (response.error) { + reject(new Error(`Authentication failed: ${response.error}`)); + return; + } + this.accessToken = response.access_token; + resolve(response.access_token); + }; + + // Check if user already has valid token + if (this.accessToken && window.gapi.client.getToken()) { + resolve(this.accessToken); + return; + } + + // Request new token + this.tokenClient.requestAccessToken({prompt: ''}); + }); + } + + /** + * Show Google Picker to select a file + * @param {Function} callback - Called when user selects a file + * @returns {Promise} Promise that resolves when picker is shown + */ + async showPicker (callback) { + if (!this.isInitialized) { + await this.initialize(); + } + + try { + // Request access token if not already available + const token = await this.requestAccessToken(); + + this.pickerCallback = callback; + + // Create and show picker + const picker = new window.google.picker.PickerBuilder() + .addView( + new window.google.picker.DocsView() + .setIncludeFolders(true) + .setMimeTypes('application/x.scratch.sb3') + ) + .addView( + new window.google.picker.DocsUploadView() + .setIncludeFolders(true) + ) + .setOAuthToken(token) + .setDeveloperKey(API_KEY) + .setCallback(this.handlePickerResponse.bind(this)) + .setTitle('Select a Scratch 3.0 project (.sb3) from Google Drive') + .build(); + + picker.setVisible(true); + } catch (error) { + console.error('Failed to show Google Picker:', error); + throw error; + } + } + + /** + * Handle picker response + * @param {object} data - Picker response data + */ + handlePickerResponse (data) { + const action = data[window.google.picker.Response.ACTION]; + + if (action === window.google.picker.Action.PICKED) { + const doc = data[window.google.picker.Response.DOCUMENTS][0]; + const fileId = doc[window.google.picker.Document.ID]; + const fileName = doc[window.google.picker.Document.NAME]; + const mimeType = doc[window.google.picker.Document.MIME_TYPE]; + + // Validate file type + if (mimeType !== 'application/x.scratch.sb3' && !fileName.endsWith('.sb3')) { + if (this.pickerCallback) { + this.pickerCallback({ + error: 'Invalid file type. Please select a .sb3 file.' + }); + } + return; + } + + // Download file + this.downloadFile(fileId, fileName) + .then(fileData => { + if (this.pickerCallback) { + this.pickerCallback({ + success: true, + fileId: fileId, + fileName: fileName, + fileData: fileData + }); + } + }) + .catch(error => { + if (this.pickerCallback) { + this.pickerCallback({ + error: `Failed to download file: ${error.message}` + }); + } + }); + } else if (action === window.google.picker.Action.CANCEL) { + if (this.pickerCallback) { + this.pickerCallback({ + cancelled: true + }); + } + } + } + + /** + * Download file from Google Drive + * @param {string} fileId - Google Drive file ID + * @param {string} fileName - File name (for debugging) + * @returns {Promise} Promise that resolves with file data as ArrayBuffer + */ + async downloadFile (fileId, fileName) { + try { + const response = await window.gapi.client.drive.files.get({ + fileId: fileId, + alt: 'media' + }); + + // Convert response to ArrayBuffer + // gapi returns the file content as a string for binary files + const binaryString = response.body; + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; + } catch (error) { + console.error(`Failed to download file ${fileName}:`, error); + throw new Error(`Failed to download file: ${error.message}`); + } + } + + /** + * Check if API is configured + * @returns {boolean} True if API credentials are configured + */ + static isConfigured () { + return !!(CLIENT_ID && API_KEY); + } +} + +// Export singleton instance +export default new GoogleDriveAPI(); diff --git a/src/lib/google-script-loader.js b/src/lib/google-script-loader.js new file mode 100644 index 00000000000..fdf1507e2b5 --- /dev/null +++ b/src/lib/google-script-loader.js @@ -0,0 +1,94 @@ +/** + * Google API Dynamic Script Loader + * + * This module provides functions to dynamically load Google API scripts + * only when needed (on first use of Google Drive functionality). + * This improves initial page load performance by avoiding unnecessary script loads. + */ + +/** + * Load a script dynamically + * @param {string} src - The script source URL + * @param {string} id - Unique identifier for the script tag + * @returns {Promise} Promise that resolves when script is loaded, rejects on error + */ +const loadScript = (src, id) => new Promise((resolve, reject) => { + // Check if script is already loaded + if (document.getElementById(id)) { + resolve(); + return; + } + + const script = document.createElement('script'); + script.id = id; + script.src = src; + script.async = true; + script.defer = true; + + script.onload = () => { + resolve(); + }; + + script.onerror = () => { + reject(new Error(`Failed to load script: ${src}`)); + }; + + document.head.appendChild(script); +}); + +/** + * Load Google API Client Library (gapi) + * Required for Google Picker API + * @returns {Promise} Promise that resolves when API is loaded + */ +export const loadGoogleAPI = () => loadScript( + 'https://apis.google.com/js/api.js', + 'google-api-script' +); + +/** + * Load Google Identity Services (GIS) + * Required for OAuth 2.0 authentication + * @returns {Promise} Promise that resolves when GIS is loaded + */ +export const loadGoogleIdentity = () => loadScript( + 'https://accounts.google.com/gsi/client', + 'google-identity-script' +); + +/** + * Load all required Google scripts + * This is the main entry point for loading Google APIs + * @returns {Promise} Promise that resolves when all scripts are loaded + */ +export const loadAllGoogleScripts = async () => { + try { + // Load both scripts in parallel + await Promise.all([ + loadGoogleAPI(), + loadGoogleIdentity() + ]); + } catch (error) { + console.error('Failed to load Google scripts:', error); + throw error; + } +}; + +/** + * Check if Google API is loaded + * @returns {boolean} True if Google API is loaded + */ +export const isGoogleAPILoaded = () => typeof window.gapi !== 'undefined'; + +/** + * Check if Google Identity Services is loaded + * @returns {boolean} True if Google Identity Services is loaded + */ +export const isGoogleIdentityLoaded = () => typeof window.google !== 'undefined'; + +/** + * Check if all required Google scripts are loaded + * @returns {boolean} True if all scripts are loaded + */ +export const areAllGoogleScriptsLoaded = () => + isGoogleAPILoaded() && isGoogleIdentityLoaded();