Google DriveとSalesforceの相互連携

こちらの記事の続きです。

https://qiita.com/masahiro_sf/items/d4cc1bfe3ffe6f75f0ed

https://qiita.com/masahiro_sf/items/f849bed6197c176615c1

Google DriveとSalesforceの相互連携を行うためのシリーズです。今回は最終回、ApexとLWCで、SalesforceからGoogle Driveへファイルをアップロードを実現します。ついでにフォルダ作成も行います。

![upsert-folder-document-link.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/ae88894d-780d-80f8-ec7b-287920e5125e.gif)


# ファイルまとめ
<details><summary>class</summary>

```apex
public with sharing class GoogleDriveController {
    @AuraEnabled
    public static Map<String, Object> uploadFileToGoogleDrive(String fileName, String fileContentBase64, String folderId) {
        try {
            String fileExtension = getFileExtension(fileName);
            String mimeType = getMimeType(fileExtension);
            String metadata = createFileMetadata(fileName, mimeType, folderId);
            
            String boundary = '-------------' + String.valueOf(DateTime.now().getTime());
            String requestBody = createMultipartBody(metadata, mimeType, fileContentBase64, boundary);
            
            HttpResponse response = makeUploadRequest(requestBody, boundary);
            return handleUploadResponse(response);
            
        } catch (Exception e) {
            System.debug('Error during upload: ' + e.getMessage());
            throw new AuraHandledException(e.getMessage());
        }
    }

    private static final Map<String, String> MIME_TYPES = new Map<String, String>{
        'jpg' => 'image/jpeg',
        'jpeg' => 'image/jpeg',
        'png' => 'image/png',
        'gif' => 'image/gif',
        'pdf' => 'application/pdf',
        'txt' => 'text/plain'
    };

    private static String createFileMetadata(String fileName, String mimeType, String folderId) {
        return '{"name": "' + fileName + 
               '", "mimeType": "' + mimeType + 
               '", "parents": ["' + folderId + '"]}';
    }
    
    private static String createMultipartBody(String metadata, String mimeType, String fileContentBase64, String boundary) {
        List<String> lines = new List<String>();
        
        // Metadata part
        lines.add('--' + boundary);
        lines.add('Content-Type: application/json; charset=UTF-8');
        lines.add('');
        lines.add(metadata);
        
        // File content part
        lines.add('--' + boundary);
        lines.add('Content-Type: ' + mimeType);
        lines.add('Content-Transfer-Encoding: base64');
        lines.add('');
        lines.add(fileContentBase64);
        
        // Final boundary
        lines.add('--' + boundary + '--');
        
        return String.join(lines, '\r\n');
    }
    
    private static HttpResponse makeUploadRequest(String requestBody, String boundary) {
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:GoogleDriveUpload/files?uploadType=multipart');
        req.setMethod('POST');
        req.setHeader('Content-Type', 'multipart/related; boundary=' + boundary);
        req.setTimeout(120000); // 2 minutes timeout
        req.setBody(requestBody);
        
        Http http = new Http();
        return http.send(req);
    }
    
    private static Map<String, Object> handleUploadResponse(HttpResponse response) {
        System.debug('Response status: ' + response.getStatusCode());
        System.debug('Response body: ' + response.getBody());
        
        if (response.getStatusCode() == 200 || response.getStatusCode() == 201) {
            Map<String, Object> responseMap = (Map<String, Object>)JSON.deserializeUntyped(response.getBody());
            return responseMap;
            //return response.getBody();
        } else {
            throw new AuraHandledException('Upload failed with status ' + 
                response.getStatusCode() + ': ' + response.getBody());
        }
    }
    
    private static String getFileExtension(String fileName) {
        system.debug('fileName: ' + fileName);
        if (String.isBlank(fileName) || !fileName.contains('.')) {
            throw new AuraHandledException('Invalid file name: ' + fileName);
        }
        return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
    }
    
    private static String getMimeType(String fileExtension) {
        String mimeType = MIME_TYPES.get(fileExtension.toLowerCase());
        if (mimeType == null) {
            throw new AuraHandledException('Unsupported file type: ' + fileExtension + 
                '. Supported types are: ' + String.join(new List<String>(MIME_TYPES.keySet()), ', '));
        }
        return mimeType;
    }

    @AuraEnabled
    public static ContentVersion createContentVersionFromGoogleFile(Map<String, Object> googleFileInfo, String linkedEntityId, String dataSourceName) {
        String fileId = (String) googleFileInfo.get('id');
        String fileName = (String) googleFileInfo.get('name');

        ContentVersion contentVersion = new ContentVersion();
        contentVersion.Title = fileName;
        contentVersion.PathOnClient = fileName;
        contentVersion.ContentLocation = 'E'; 
        contentVersion.Origin = 'H'; 
        contentVersion.ExternalDocumentInfo1 = 'https://drive.google.com/file/d/' + fileId + '/view?usp=drivesdk';
        contentVersion.ExternalDocumentInfo2 = 'file:' + fileId;
        contentVersion.ExternalDataSourceId = getExternalDataSourceId(dataSourceName); // 重要
        
        insert contentVersion;
        
        ContentVersion currentContentVersion = [SELECT Id, ContentDocumentId FROM ContentVersion WHERE Id = :contentVersion.Id WITH SYSTEM_MODE LIMIT 1];
        system.debug('currentContentVersion: ' + currentContentVersion);

        ContentDocumentLink contentDocumentLink = new ContentDocumentLink();
        contentDocumentLink.LinkedEntityId = linkedEntityId;
        contentDocumentLink.ContentDocumentId = currentContentVersion.ContentDocumentId;
        contentDocumentLink.ShareType = 'V'; 
        contentDocumentLink.Visibility = 'AllUsers'; 
        system.debug('contentDocumentLink: ' + contentDocumentLink);
        
        insert contentDocumentLink;
        return currentContentVersion;
    }

    private static Id getExternalDataSourceId(String name) {
        ExternalDataSource externalDataSource = [SELECT Id FROM ExternalDataSource WHERE DeveloperName = :name LIMIT 1];
        return externalDataSource.Id;
    }

    @AuraEnabled
    public static String upsertFolder(String folderName, String parentFolderName) {
        String parentFolderId = getFolderMetadata(parentFolderName).Folder_Id__c;   
        String folderId = findFolder(folderName, parentFolderId);   
        if (folderId != null) {
            return folderId; 
        }  
        return createFolder(folderName, parentFolderId);
    }

    public static String findFolder(String folderName, String parentFolderId) {
            String query = 'name = \'' + folderName + '\' and mimeType = \'application/vnd.google-apps.folder\' and trashed = false and \'' + parentFolderId + '\' in parents';
            HttpRequest req = new HttpRequest();
            req.setEndpoint('callout:GoogleDrive/files?q=' + EncodingUtil.urlEncode(query, 'UTF-8'));
            req.setMethod('GET');
            req.setHeader('Content-Type', 'application/json');
        
            Http http = new Http();
            HttpResponse res = http.send(req);
    
        if (res.getStatusCode() == 200) {
            Map<String, Object> jsonResponse = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
            List<Object> files = (List<Object>) jsonResponse.get('files');
            if (!files.isEmpty()) {
                return (String)((Map<String, Object>) files[0]).get('id'); // Return the folder ID
            }
        } else {
            System.debug('Error finding folder: ' + res.getBody());
        }
        return null; // Folder not found
    }

    public static String createFolder(String folderName, String parentFolderId) {
        Map<String, Object> requestBody = new Map<String, Object>{
            'name' => folderName,
            'mimeType' => 'application/vnd.google-apps.folder',
            'parents' => new List<String>{ parentFolderId }
        };
    
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:GoogleDrive/files');
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        req.setBody(JSON.serialize(requestBody));
    
        Http http = new Http();
        HttpResponse res = http.send(req);
    
        if (res.getStatusCode() == 200 || res.getStatusCode() == 201) {
            Map<String, Object> jsonResponse = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
            return (String) jsonResponse.get('id'); // Return the new folder ID
        } else {
            System.debug('Error creating folder: ' + res.getBody());
        }
        return null; // Folder creation failed
    }

    @AuraEnabled
    public static GoogleDrive__mdt getFolderMetadata(String parentFolderName) {
        // Query to get up to 200 metadata records
        GoogleDrive__mdt folderMetadata = [SELECT DeveloperName, Folder_Id__c FROM GoogleDrive__mdt WHERE DeveloperName =:parentFolderName LIMIT 1];
        return folderMetadata;
    }
}
```
</details>

<details><summary>html</summary>

```html
<template>
    <lightning-card hide-header="true">
        <div
            class="slds-grid slds-wrap slds-grid_vertical-align-center slds-page-header forceRelatedListCardHeader slds-p-vertical_small">
            <lightning-button lwc:if={isBlankFolder} label="Create Google Drive Folder" onclick={upsertFolder}
                class={buttonUploadClass}>
            </lightning-button>
            <template lwc:if={isCreating}>
                <lightning-spinner alternative-text="Creating..." size="x-small"></lightning-spinner>
            </template>
            <div lwc:if={isNotBlankFolder} class="slds-grid slds-grid_vertical-align-center slds-grid_align-spread">
                <img src=" /img/icon/contentHubGDrive_32.png" width="24px" alt="Google Drive" />
                <lightning-formatted-url value={driveFolderUrl} label={driveFolderName}
                    class="slds-m-horizontal_x-small slds-card__header-title" target="_blank"></lightning-formatted-url>
                <div lwc:if={copyAvailable}>
                    <a href="javascript:void(0);" onclick={copyFolderUrl} class="slds-m-left_x-small">
                        <lightning-icon icon-name="utility:copy_to_clipboard" size="x-small" alternative-text="Copy"
                            variant={copyIconVariant} class="copy-icon"></lightning-icon>
                    </a>
                    <span class="slds-m-left_x-small">{copyText}</span>
                </div>
                <div lwc:else
                    class="slds-theme_error slds-p-horizontal_x-small slds-p-vertical_xx-small slds-page-header">
                    <h2 class="slds-text-color_inverse">外部共有禁止</h2>
                </div>
            </div>
        </div>

        <div class="slds-grid slds-grid_vertical-align-center slds-p-around_small slds-border_top">
            <lightning-input multiple disabled={isBlankFolder} type="file" variant="label-hidden"
                onchange={handleFileChange}></lightning-input>
            <lightning-button disabled={isUploadDisabled} label="Upload to Google" variant="brand"
                class="slds-m-left_x-small" onclick={handleUpload}></lightning-button>
        </div>
        <div class="slds-grid slds-wrap slds-grid_vertical-align-center slds-p-horizontal_x-small">
            <template lwc:if={files}>
                <template for:each={selectedFiles} for:item="file">
                    <lightning-pill key={file.name} name={file.name} label={file.name} onremove={handleRemoveFile}
                        class={selectedItemClass}>
                        <lightning-icon icon-name={file.iconName} alternative-text={file.iconName}>
                        </lightning-icon>
                    </lightning-pill>
                </template>
                <template lwc:if={isUploading}>
                    <lightning-spinner alternative-text="Uploading..." size="x-small"></lightning-spinner>
                </template>
            </template>
        </div>
    </lightning-card>
</template>
```
</details>
<details><summary>js</summary>

```javascript
import { LightningElement, wire, api } from 'lwc';
import uploadFileToGoogleDrive from '@salesforce/apex/GoogleDriveController.uploadFileToGoogleDrive';
import createContentVersionFromGoogleFile from '@salesforce/apex/GoogleDriveController.createContentVersionFromGoogleFile';
import upsertFolder from '@salesforce/apex/GoogleDriveController.upsertFolder';

import ACCOUNT_UNIQUE_NUMBER_FIELD from '@salesforce/schema/Account.AccountNumber';
import ACCOUNT_NAME_FIELD from '@salesforce/schema/Account.Name';
import ACCOUNT_DRIVE_FOLDER_ID_FIELD from '@salesforce/schema/Account.Drive_Folder_Id__c';
import ACCOUNT_DRIVE_FOLDER_NAME_FIELD from '@salesforce/schema/Account.Drive_Folder_Name__c';

import { getRecord, updateRecord } from 'lightning/uiRecordApi';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { RefreshEvent } from "lightning/refresh";

export default class GoogleDrive extends LightningElement {
    @api recordId;
    @api flexipageRegionWidth;
    @api objectApiName;
    files = [];
    selectedFiles = [];
    isUploading = false;
    isCreating = false;
    driveFolderId;
    driveFolderName = '';
    driveFolderUrl = '';
    copyAvailable = false;
    copyIconVariant = 'utility:copy';
    uniqueNumber;
    uniqueName;
    copyText = '';
    copyIconVariant = 'brand';

    get isUploadDisabled() {
        return this.files.length === 0;
    }

    get isBlankFolder() {
        return !this.driveFolderId;
    }

    get isNotBlankFolder() {
        return this.driveFolderId;
    }

    get buttonUploadClass() {
        return this.flexipageRegionWidth === 'SMALL' ? 'slds-p-right_x-small' : 'slds-p-left_small slds-m-around_xx-small';
    }

    get folderSelectClass() {
        return this.flexipageRegionWidth === 'SMALL' ? 'slds-p-right_x-small slds-size_1-of-2' : 'slds-p-left_small slds-m-around_xx-small slds-size_1-of-6';
    }

    get selectedItemClass() {
        return this.flexipageRegionWidth === 'SMALL' ? 'slds-p-right_x-small slds-p-bottom_x-small' : 'slds-m-around_xx-small';
    }

    get fields() {
        return [
            ACCOUNT_UNIQUE_NUMBER_FIELD,
            ACCOUNT_NAME_FIELD,
            ACCOUNT_DRIVE_FOLDER_ID_FIELD,
            ACCOUNT_DRIVE_FOLDER_NAME_FIELD
        ];
    }

    @wire(getRecord, { recordId: '$recordId', fields: '$fields' })
    wiredRecord({ error, data }) {
        if (data) {
            const baseUrl = 'https://drive.google.com/drive/folders/';
            this.uniqueNumber = data.fields[ACCOUNT_UNIQUE_NUMBER_FIELD.fieldApiName].value;
            this.uniqueName = data.fields[ACCOUNT_NAME_FIELD.fieldApiName].value;
            this.driveFolderName = data.fields[ACCOUNT_DRIVE_FOLDER_NAME_FIELD.fieldApiName].value;
            this.driveFolderId = data.fields[ACCOUNT_DRIVE_FOLDER_ID_FIELD.fieldApiName].value;
            this.driveFolderUrl = baseUrl + this.driveFolderId;
            this.copyAvailable = true;
            this.copyIconVariant = 'utility:copy';
        }
        if (error) {
            console.error('Error loading record:', error);
        }
    }

    handleFileChange(event) {
        this.files = Array.from(event.target.files);
        this.selectedFiles = this.files.map(file => ({
            name: file.name,
            iconName: this.getFileIcon(file.type)
        }));
    }

    getFileIcon(fileType) {
        const icons = {
            'application/pdf': 'doctype:pdf',
            'image/jpeg': 'doctype:image',
            'image/jpg': 'doctype:image',
            'image/png': 'doctype:image',
            'application/msword': 'doctype:word',
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'doctype:word',
            'application/vnd.ms-excel': 'doctype:excel',
            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'doctype:excel',
            'text/plain': 'doctype:txt'
        };
        return icons[fileType] || 'doctype:unknown';
    }

    handleUpload() {
        this.isUploading = true;
        Promise.all(this.files.map(file => this.uploadFile(file)))
            .then((result) => {
                console.log('result: ', result);
                const createContentVersionPromises = result.map(file => this.createContentVersion(file));
                return Promise.all(createContentVersionPromises);
            })
            .catch(error => {
                console.error('Error uploading files: ', error);
                this.dispatchEvent(
                    new ShowToastEvent({
                        title: 'Error uploading files',
                        message: error.body.message,
                        variant: 'error'
                    })
                );
            })
            .finally(() => {
                this.finishUploading();
            });
    }

    async uploadFile(file) {
        const base64Content = await this.convertFileToBase64(file);
        console.log('Base64 content: ', base64Content);
        const params = {
            fileName: file.name,
            fileContentBase64: base64Content,
            folderId: this.driveFolderId
        };
        console.log('params: ', params);
        return uploadFileToGoogleDrive(params);
    }

    convertFileToBase64(file) {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = () => resolve(reader.result.split(',')[1]);
            reader.onerror = reject;
            reader.readAsDataURL(file);
        });
    }

    handleRemoveFile(event) {
        const fileName = event.detail.name;
        this.selectedFiles = this.selectedFiles.filter(file => file.name !== fileName);
        this.files = this.files.filter(file => file.name !== fileName);
    }

    finishUploading() {
        this.isUploading = false;
        this.files = [];
        this.selectedFiles = [];
        this.dispatchEvent(
            new ShowToastEvent({
                title: 'Success',
                message: 'Files uploaded successfully',
                variant: 'success'
            })
        );
        console.log('RefreshEvent');
        this.dispatchEvent(new RefreshEvent());
    }

    createContentVersion(file) {
        const googleFileInfo = {
            'id': file.id,
            'name': file.name,
            'mimeType': file.mimeType
        };
        const params = {
            googleFileInfo: googleFileInfo,
            linkedEntityId: this.recordId,
            dataSourceName: 'GoogleDrive'
        };
        return createContentVersionFromGoogleFile(params);
    }

    upsertFolder() {
        this.isCreating = true;
        if (this.objectApiName !== '') {
            const folderName = `${this.uniqueNumber} ${this.uniqueName}`;
            upsertFolder({ folderName: folderName, parentFolderName: this.objectApiName })
                .then(result => {
                    const fields = {};
                    switch (this.objectApiName) {
                        case 'Account':
                            fields[ACCOUNT_DRIVE_FOLDER_ID_FIELD.fieldApiName] = result;
                            fields[ACCOUNT_DRIVE_FOLDER_NAME_FIELD.fieldApiName] = folderName;
                            break;
                        default:
                    }
                    fields['Id'] = this.recordId;
                    this.isCreating = false; // finish spinner while creating
                    const recordInput = { fields };
                    updateRecord(recordInput)
                        .then(() => {
                            this.dispatchEvent(
                                new ShowToastEvent({
                                    title: 'Success',
                                    message: 'Folder upserted and the record updated successfully!',
                                    variant: 'success',
                                }),
                            );
                        })
                        .catch(error => {
                            console.error('Error updating Case record:', error);
                            this.dispatchEvent(
                                new ShowToastEvent({
                                    title: 'Error',
                                    message: 'Folder upserted but Case record update failed: ' + error.body.message,
                                    variant: 'error',
                                }),
                            );
                        });
                })
                .catch(error => {
                    console.error('Error upserting folder:', error);
                    this.dispatchEvent(
                        new ShowToastEvent({
                            title: 'Error',
                            message: 'Folder upsert failed: ' + error.body.message,
                            variant: 'error',
                        }),
                    );
                });
        }
    }

    async copyFolderUrl() {
        try {
            await navigator.clipboard.writeText(this.driveFolderUrl);
            this.copyText = 'Copied!';
            this.copyIconVariant = 'inverse';
        } catch (err) {
            this.copyText = 'Failed to copy - please check permissions';
            console.error('Copy failed:', err);
        }
    }
}
```
</details>
<details><summary>xml</summary>

```xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>62.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__RecordPage</target>
    </targets>
</LightningComponentBundle>
```
</details>

<details><summary>css</summary>

```css
:host {
  --slds-c-card-header-spacing-block-start: 0;
  --slds-c-card-header-spacing-block-end: 0;
  --slds-c-card-body-spacing-block-start: 0;
  --slds-c-card-body-spacing-block-end: 0;
}

.copy-icon:hover {
  --sds-c-icon-color-foreground-default: var(
    --sds-c-icon-color-foreground-hover
  );
}
```
</details>

# 新規項目まとめ
<details><summary>New Fields</summary>

```javascript
Account.Drive_Folder_Id__c
Account.Drive_Folder_Name__c
```
</details>

<details><summary>Custom Metadata</summary>

```
Custom Metadata Type:
GoogleDrive

Custom Metadata Field:
Folder_Id__c
```
</details>


# 手順
1. `Class`の作成
    1. `uploadFileToGoogleDrive`メソッドの作成
    1. アップロードの検証
1. `LWC`を作成
    1. `Upload Files`ボタンのアクションを追加
    1. `pill`の削除のアクションを追加
    1. `Upload to Google`ボタンのアクションを追加
    1. `Files`の追加
1. `フォルダ`を作成
    1. `External Data Source`のCustom MetaData管理
    1. `driveFolderId`の動的処理

# Classの作成
まず`GoogleDriveController` classを作成してみます。その中で、`uploadFileToGoogleDrive`のメソッドを作成します。
## Apexで`uploadFileToGoogleDrive`のメソッドの作成
<details open><summary>uploadFileToGoogleDrive</summary>

```apex
@AuraEnabled
public static Map<String, Object> uploadFileToGoogleDrive(String fileName, String fileContentBase64, String folderId) {
    try {
        String fileExtension = getFileExtension(fileName);
        String mimeType = getMimeType(fileExtension);
        String metadata = createFileMetadata(fileName, mimeType, folderId);
        
        String boundary = '-------------' + String.valueOf(DateTime.now().getTime());
        String requestBody = createMultipartBody(metadata, mimeType, fileContentBase64, boundary);
        
        HttpResponse response = makeUploadRequest(requestBody, boundary);
        return handleUploadResponse(response);
        
    } catch (Exception e) {
        System.debug('Error during upload: ' + e.getMessage());
        throw new AuraHandledException(e.getMessage());
    }
}
```
</details>

最終的にはhttpResponseに`makeUploadRequest`からフォーマットされたrequestBodyとboundaryを渡すのを目的に作成します。ただし、これにはprivate methodが以下のように複数含まれているのでそちらも同時に作成します。
- `makeUploadRequest`
- `getFileExtension` 
- `getMineType` 
- `createFileMetadata`
- `createMultipartBody`
- `handleUploadResponse`

<details open><summary>makeUploadRequest</summary>

```apex
private static HttpResponse makeUploadRequest(String requestBody, String boundary) {
    HttpRequest req = new HttpRequest();
    req.setEndpoint('callout:GoogleDriveUpload/files?uploadType=multipart');
    req.setMethod('POST');
    req.setHeader('Content-Type', 'multipart/related; boundary=' + boundary);
    req.setTimeout(120000); // 2 minutes timeout
    req.setBody(requestBody);
    
    Http http = new Http();
    return http.send(req);
}
```
httpRequestのEndPointは `callout:GoogleDriveUpload/files?uploadType=multipart'`です。Calloutは`GoogleDriveUpload`を使用します。こちらはurlに`/upload/drive/v3`が指定しているので、`/files`を続けて、`?`で変数を当てていけます。
[Named Credential（Upload用）
](https://qiita.com/masahiro_sf/items/f849bed6197c176615c1#named-credentialupload%E7%94%A8)

また、多くのメタデータを渡すので`uploadType=multipart`を利用します。このuploadTypeを利用することによって、親フォルダの指定をしたり、ファイルのname, mimeType, descriptionなどを追加してアップロードできます。

参照: [uploadType=media](https://developers.google.com/drive/api/guides/manage-uploads#simple)
参照: [uploadType=multipart](https://developers.google.com/drive/api/guides/manage-uploads#http_1) 
参照: [uploadType=resumable](https://developers.google.com/drive/api/guides/manage-uploads#resumable)
</details>

<details open><summary>getFileExtension</summary>

```apex
private static String getFileExtension(String fileName) {
    if (String.isBlank(fileName) || !fileName.contains('.')) {
        throw new AuraHandledException('Invalid file name: ' + fileName);
    }
    return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
}

```
</details>

<details open><summary>getMimeType</summary>

```apex
private static String getMimeType(String fileExtension) {
    String mimeType = MIME_TYPES.get(fileExtension.toLowerCase());
    if (mimeType == null) {
        throw new AuraHandledException('Unsupported file type: ' + fileExtension + 
            '. Supported types are: ' + String.join(new List<String>(MIME_TYPES.keySet()), ', '));
    }
    return mimeType;
}
```
</details>


<details open><summary>createFileMetadata</summary>

```apex
private static String createFileMetadata(String fileName, String mimeType, String folderId) {
    return '{"name": "' + fileName + 
           '", "mimeType": "' + mimeType + 
           '", "parents": ["' + folderId + '"]}';
}
```
</details>


<details open><summary>createMultipartBody</summary>

```apex
private static String createMultipartBody(String metadata, String mimeType, String fileContentBase64, String boundary) {
    List<String> lines = new List<String>();
    
    // Metadata part
    lines.add('--' + boundary);
    lines.add('Content-Type: application/json; charset=UTF-8');
    lines.add('');
    lines.add(metadata);
    
    // File content part
    lines.add('--' + boundary);
    lines.add('Content-Type: ' + mimeType);
    lines.add('Content-Transfer-Encoding: base64');
    lines.add('');
    lines.add(fileContentBase64);
    
    // Final boundary
    lines.add('--' + boundary + '--');
    
    return String.join(lines, '\r\n');
}
```
</details>

<details open><summary>handleUploadResponse</summary>

```apex
private static Map<String, Object> handleUploadResponse(HttpResponse response) {
    System.debug('Response status: ' + response.getStatusCode());
    System.debug('Response body: ' + response.getBody());
    
    if (response.getStatusCode() == 200 || response.getStatusCode() == 201) {
        Map<String, Object> responseMap = (Map<String, Object>)JSON.deserializeUntyped(response.getBody());
        return responseMap;
    } else {
        throw new AuraHandledException('Upload failed with status ' + 
            response.getStatusCode() + ': ' + response.getBody());
    }
}
```
</details>

これにより最終的に`uploadFileToGoogleDrive` メソッドが完成します。のちにLWC側でimportして利用します。

## アップロードの検証
`GoogleDriveController`をデプロイしたのち、Anonymous Windowで同クラスのメソッドを呼び出してアップロードが可能か検証します。
<details open><summary>検証Apex</summary>

```apex
String fileName = 'Text Plain.txt';
String fileContent = 'Test Content Encoded';
String fileContentBase64 = EncodingUtil.base64Encode(Blob.valueOf(fileContent));
String folderId = 'root';
GoogleDriveController.uploadFileToGoogleDrive(fileName, fileContentBase64, folderId);
```
![apex-without-folder-Id.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/f9ba68b1-4080-c628-4701-3fb35368181a.png)

こちらでuploadFileToGoogleDriveに渡すと、txtファイルが作成されているのが確認できたら成功です。folderIdはrootを指定しているので、MyDrive配下にできます。

![recent-text.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/77772e62-b874-cd46-8b03-3cb8a68d5e2e.png)

</details>

#### folderIdありで検証
次に、Google Drive内に新規のフォルダを作成してみましょう。名前は任意ですのでここでは`Dev`とつけて作成、URLからフォルダの`id`をコピペして、フォルダIdに当てて呼んでみましょう。

![folder-dev-id.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/d0949e7c-7564-d977-3e9a-58e2e8e74799.png)


<details open><summary>検証Apex (folderIdあり）</summary>

```apex
String fileName = 'TestFile.txt';
String fileContent = 'Test Content Encoded';
String fileContentBase64 = EncodingUtil.base64Encode(Blob.valueOf(fileContent));
String folderId = '1eE6*****************MO3'; // 実際のフォルダIdを入れてください
GoogleDriveController.uploadFileToGoogleDrive(fileName, fileContentBase64, folderId);
```
![apex-with-folder-Id.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/93cb3ad5-2e65-00e3-ccd6-a267cb66fdd2.png)

実行後、`Dev`フォルダ配下にファイルが作成されていれば、GoogleDriveController.uploadFileToGoogleDriveのメソッドの検証は成功です。
![apex-with-folder-Id-finished.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/582cd271-fbbe-ee13-05be-17c0645a5338.png)

# LWCを作成
以上でControllerの基本的fileUploadメソッドの作成は終わりました。それでは続いてLWCで以下の単純なボタンのみあるコンポートネントを作成します。
- `ファイルをアップロードするアクション` (lightning-input) 
- `Googleへアップロードするボタン` (lightning-button) 

![lwc-googledrive-component.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/ec8f5b52-60a3-d726-4999-7407613ac393.png)

## 構成
### htmlファイルの作成
構成はとりあえず最低限で `lightning-input`と `lightning-button`を`div`で囲い `slds-grid`で縦方向中央揃えします。

<details open><summary>html</summary>

```html
<template>
    <lightning-card>
        <div class="slds-grid slds-grid_vertical-align-center slds-p-around_small">
            <lightning-input multiple type="file" variant="label-hidden" onchange={handleFileChange}></lightning-input>
            <lightning-button disabled={isUploadDisabled} label="Upload to Google" variant="brand"
                class="slds-m-left_x-small" onclick={handleUpload}></lightning-button>
        </div>
    </lightning-card>
</template>
```

#### lightning-card
今後テストでいくつかボタンを追加していきます。それを`lightning-card`で囲って、パーツぽくまとめます。

#### lightning-input
`lightning-input`は面白くて、type=`file`にすることでファイルアップロードモジュールになります。扱うファイルは`multiple`パラメーターの追加で複数ファイルを扱うようになります。

参照: [lightning-card](https://developer.salesforce.com/docs/component-library/bundle/lightning-card) / [lightning-input](https://developer.salesforce.com/docs/component-library/bundle/lightning-input/) / [lightning-button](https://developer.salesforce.com/docs/component-library/bundle/lightning-button/) / [slds-grid](https://www.lightningdesignsystem.com/utilities/grid/#Alignment)

</details>

### jsファイルの作成
今はほぼ空で。次の工程で複数のメソッドを追加していきます。
<details open><summary>js</summary>

```javascript
import { LightningElement, api } from 'lwc';
import uploadFileToGoogleDrive from '@salesforce/apex/GoogleDriveController.uploadFileToGoogleDrive';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';

export default class GoogleDrive extends LightningElement {
    @api recordId;
    @api flexipageRegionWidth;
}
```
#### api decorator
とりあえず LightningElementの後にカンマでapiを追加して`@api` decoratorを有効にして`recordId`と`flexipageRegionWidth`を取得します。どちらも後の工程で利用します。
#### import method
前工程で作成してある`uploadFileToGoogleDrive`メソッドもimportしておきましょう。
#### import toast
またお馴染の`ShowToastEvent`も処理完了の通知で利用するのでimportしておきましょう。
</details>

### js-meta.xml
exposedを`true`にするのと<targets>で`lightning__RecordPage`のみ追加します。これでレコードページにLWCを配置できるようになります。

<details open><summary>js-meta.xml</summary>

```xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>62.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__RecordPage</target>
    </targets>
</LightningComponentBundle>
```
</details>

### 添付ファイルの表示（onremoveアクションあり）
さらに、追加のhtmlです。添付ファイルの存在を表示させて、アップロードに準備されているファイルがいくつあるのか、以下のhtmlを追加します。Upload Filesに渡されたfilesをわかりやすいようにpill表示します。（onremoveアクションあり）

<details open><summary>files表示</summary>

```html
<div class="slds-grid slds-wrap slds-grid_vertical-align-center slds-p-around_x-small">
    <template lwc:if={files}>
        <template for:each={selectedFiles} for:item="file">
            <lightning-pill key={file.name} name={file.name} label={file.name} onremove={handleRemoveFile}
                class={selectedItemClass}>
                <lightning-icon icon-name={file.iconName} alternative-text={file.iconName}>
                </lightning-icon>
            </lightning-pill>
        </template>
        <template lwc:if={isUploading}>
            <lightning-spinner alternative-text="Uploading..." size="x-small"></lightning-spinner>
        </template>
    </template>
</div>
```

参照: [lightning-pill](https://developer.salesforce.com/docs/component-library/bundle/lightning-pill) / [lightning-spinner](https://developer.salesforce.com/docs/component-library/bundle/lightning-spinner/)
</details>



## Upload Filesボタンのアクションを追加
とりあえずコンポーネントは配置できたのを確認したのち、`Upload Files`ボタンにファイルをドラッグ&ドロップ（もしくはクリックしてファイル選択）をした際の挙動として、ファイルをアップロードした後の処理を追加していきます。

<details open><summary>jsにhandleFileChangeとgetFileIconを追加</summary>

```javascript
import { LightningElement, api } from 'lwc';
import uploadFileToGoogleDrive from '@salesforce/apex/GoogleDriveController.uploadFileToGoogleDrive';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';

export default class GoogleDrive extends LightningElement {
    @api recordId;
    @api flexipageRegionWidth;
    files = [];
    selectedFiles = [];
    isUploading = false;

    handleFileChange(event) {
        this.files = Array.from(event.target.files);
        this.selectedFiles = this.files.map(file => ({
            name: file.name,
            iconName: this.getFileIcon(file.type)
        }));
    }

    getFileIcon(fileType) {
        const icons = {
            'application/pdf': 'doctype:pdf',
            'image/jpeg': 'doctype:image',
            'image/jpg': 'doctype:image',
            'image/png': 'doctype:image',
            'application/msword': 'doctype:word',
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'doctype:word',
            'application/vnd.ms-excel': 'doctype:excel',
            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'doctype:excel',
            'text/plain': 'doctype:txt'
        };
        return icons[fileType] || 'doctype:unknown';
    }
}
```
</details>

### handleFileChange
コンポーネントにファイルを追加するたびにこちらのメソッドが呼ばれるのでその処理をします。
`files`と`selectedFiles`の配列にgetFileIconでdoctypeアイコン形式に変換した配列として格納していきます。

### getFileIcon
linghting-inputで渡されるデータは基本的に以下のようになります。![file-console-log.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/91d418d4-e35a-af98-0798-3e4b58325cea.png)

| key |
|:-|
| lastModified |
| lastModifiedDate |
| name |
| size |
| type　（こちらを使用） |
| webkitRelativePath |

lightning-iconに渡してslds iconsのdoctypeで使用可能な文字列に変換しておきます。メジャーなものだけを追加したので必要に応じてcaseを増やしてみてください。
[slds doctype](https://www.lightningdesignsystem.com/icons/#doctype)
![slds-icons-doctype.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/fdef1ebc-1ccf-eb5c-7c01-7feabed793fd.png)

## Upload to Googleボタンを有効化する
filesが0以上の時にのみUpload to Googleを有効化します。
<details open><summary>isUploadDisabledのgetterを設定</summary>

```javascript
get isUploadDisabled() {
  return this.files.length === 0;
}
```
</details>

<details open><summary>lightning-button disabledを設定</summary>

```html
<lightning-button disabled={isUploadDisabled} label="Upload to Google" 
variant="label-hidden" class="slds-m-left_x-small" onclick={handleUpload}>
</lightning-button>
```
</details>


したがって、Upload to Googleのボタンのdisabledは、`disabled={isUplaodDisabled}`となります。こうすることによってファイルがアップロードされたらクリックできるようなボタンの完成です。

## pillの削除のアクションを追加

![remove-pill.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/dd8ddb58-425b-547e-5d36-e73fbe7fd4bb.gif)

<details open><summary>onremoveのhandleRemoveFileアクションを追加</summary>

```javascript
handleRemoveFile(event) {
    const fileName = event.detail.name;
    this.selectedFiles = this.selectedFiles.filter(file => file.name !== fileName);
    this.files = this.files.filter(file => file.name !== fileName);
}

get selectedItemClass() {
    return this.flexipageRegionWidth === 'SMALL' ? 'slds-p-right_x-small slds-p-top_x-small' : 'slds-m-around_xx-small';
}
```
#### handleRemoveFile
pill要素には`handleRemoveFile`がonremoveで呼べるので、こちらのメソッドを用意します。クリックするたびに、一度Upload Filesであげたものをキャンセルすることができます。

#### selectedItemClass
また、getterで、`selectedItemClass`を作成します。`this.flexipageRegionWidth`はとても便利でコンポーネントが置かれた領域の大きさが取れます。それを利用してコンポーネントが大きい領域に配置された時と小さい領域に配置された時で、スペーシングが異なるような挙動も入れます。必須ではないですが見た目が整います。
参照: [flexipageRegionWidth](https://developer.salesforce.com/docs/platform/lwc/guide/use-width-aware.html)

</details>


## Upload to Google ボタンのアクションを追加
上記でファイルは取得できているので、次はそれをGoogle Driveへアップロードするアクションを追加していきます。

![upload-to-google-demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/15c67735-e557-fee1-c092-2763b1437813.gif)


<details open><summary>handleUploadによるfilesの処理</summary>

```javascript
handleUpload() {
    this.isUploading = true;
    Promise.all(this.files.map(file => this.uploadFile(file)))
       .then((result) => {
        console.log('result: ', result);
        })
        .finally(() => {
            this.finishUploading();
        });
}

async uploadFile(file) {
    const base64Content = await this.convertFileToBase64(file);
    const params = {
        fileName: file.name,
        fileContentBase64: base64Content,
        folderId: 'root'// とりあえずroot 指定します。MyDriveへ入ります
    };
    return uploadFileToGoogleDrive(params); 
}

convertFileToBase64(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = () => resolve(reader.result.split(',')[1]);
        reader.onerror = reject;
        reader.readAsDataURL(file);
    });
}

finishUploading() {
    this.isUploading = false;
    this.files = [];
    this.selectedFiles = [];
    this.dispatchEvent(
        new ShowToastEvent({
            title: 'Success',
            message: 'Files uploaded successfully',
            variant: 'success'
        })
    );
}
```
</details>

#### handleUpload
`handleUpload`は単数もしくは複数のファイルをPromiseAllで処理していきます。処理の工程の中で`uploadFile`としてファイルデータをbase64に変換してparamとして保管し、Apexのメソッドを呼び出します。

#### uploadFile
foldconverFileToBase64で帰ってきたfile情報を、folderIdに向けて上げていくアクションでです。とりあえずfolderIdをベタ打ちで設定しましょう。後ほどセキュアで動的なfolderIdの設定にアップデートします。

#### convertFileToBase64
また`convertFileToBase64`によりbase64への変換もPromiseとして返します。 async awaitでその処理をつど待つことになります。処理が終わればPromiseAllの最終処理が呼ばれます。

#### finishUploading
こちらは、処理の最初にisUploading変数をtrueへ設定し、spinnerを回す状態にして、PromiseAllが終了したのちの処理をまとめたものです。filesやselectedFilesを空に戻したりSpinnerを止めたりします。ToastEventもあると明示的にアップロードの終了がわかるのでユーザーにとって丁寧な通知になります。

:::note info
base64について

LWCからApexにbinary data(0101...で表現されたデータ) を渡す方法がないです。またセキュリティ上ファイルデータを安全に渡すためにバイナリデータからbase64文字列 ([0-9a-zA-Z+/]*)に変換する必要があり、JavaScriptとApexどちらともにその機能はあります。今回のLWCでは、JavaScriptで変換を行なっています。一方Anonymous Windowでのテストでは、ApexにあるEncodingUtil.base64Encode()を利用して行なっています。

参照: [JavaScriptでBase64エンコード・デコード](https://qiita.com/i15fujimura1s/items/6fa5d16b1e53f04f3b06)
参照: [base64ってなんぞ？？理解のために実装してみた
](https://qiita.com/PlanetMeron/items/2905e2d0aa7fe46a36d4)

<details><summary>JavaScriptにおけるbase64へのencodingの動き</summary>

```javascript
convertFileToBase64(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = () => resolve(reader.result.split(',')[1]);
        reader.onerror = reject;
        reader.readAsDataURL(file);
    });
}
```
reader.readAsDataURL(file) を呼び出した後、reader.onload イベントハンドラ内で base64 変換が行われています。具体的には、reader.result.split(',')[1] の部分で base64 文字列が抽出されます。
1. new FileReader() で FileReader オブジェクトを作成
1. reader.readAsDataURL(file) メソッドを呼び出すと、ファイルが base64 エンコードされたデータ URL に変換
1. 変換が完了すると onload イベントが発生
1. reader.result には "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD..." のようなフォーマットの文字列が含まれる
1. split(',')[1] で base64 部分（/9j/4AAQSkZJRgABAQEAYABgAAD...）だけを抽出
1. resolve() メソッドで Promise を解決し、base64 文字列を返す

この実装により、渡されたファイルが効率的に base64 文字列に変換されます。
</details>
:::

次は、`return uploadFileToGoogleDrive(params); `こちらでファイル作成のメソッドを呼び出し、Google Driveにファイルが作成されていることと、結果作成されたファイルのアドレスとIdが戻り値としてきているのを確認します。

## Filesに追加
`handleUpload`でPromiseAllによりアップロードを終えたのち、ファイルのGoogle Driveのデータが戻ってきます。それを利用してContentDocumentLinkを作成します。Google Driveにあるファイルの参照リンクは`ExternalDocumentInfo1`と`ExternalDocumentInfo2`に保存されて`Reference`としてのみ機能します。

### 戻り値を確認する
複数のファイルをアップロードすれば複数の戻り値が配列で戻ってきます。これを利用して次でContentDocumentLinkを作成します。
<details open><summary>uploadFileの戻り値の確認</summary>

```javascript
handleUpload() {
    this.isUploading = true;
    Promise.all(this.files.map(file => this.uploadFile(file)))
        .then((result) => {
            console.log('result: ', result);
        })
        .finally(() => {
            this.finishUploading();
        });
}
```
</details>

<details open><summary>uploadFileの戻り値</summary>

```javascript
[
    {
        kind: 'drive#file',
        id: '104*********************S_hcQ', // 実際は別のfileId
        name: 'cat-king-craddle.png', 
        mimeType: 'image/png'
    }
];
```
| key | value |
|:-|:-|
| kind | drive#file |
| id  | 104*********************S_hcQ |
| name | cat-king-craddle.png |
| mimeType | image/png |

こちらは配列なので、そのまま次の項目のApexメソッドへ投げ込みます。
</details>

### ApexでContentVersionとDocumentLinkを作成
配列を高階関数で渡す先のメソッドですが、受け取った情報からまずContentVersionを作成しContentDocumentLinkを追加することでGoogle DriveへあげたFileを既存のFilesリストへ追加することができます。自作することもできますが、既存のFilesリストはPreviewもできるので便利です。
<details open><summary>Apex (ContentVersionとDocumentLink)</summary>

```apex
@AuraEnabled
public static ContentVersion createContentVersionFromGoogleFile(Map<String, Object> googleFileInfo, String linkedEntityId, String dataSourceName) {
    String fileId = (String) googleFileInfo.get('id');
    String fileName = (String) googleFileInfo.get('name');

    ContentVersion contentVersion = new ContentVersion();
    contentVersion.Title = fileName;
    contentVersion.PathOnClient = fileName;
    contentVersion.ContentLocation = 'E'; 
    contentVersion.Origin = 'H'; 
    contentVersion.ExternalDocumentInfo1 = 'https://drive.google.com/file/d/' + fileId + '/view?usp=drivesdk';
    contentVersion.ExternalDocumentInfo2 = 'file:' + fileId;
    contentVersion.ExternalDataSourceId = getExternalDataSourceId(dataSourceName); // 重要
    
    insert contentVersion;
    
    ContentVersion currentContentVersion = [SELECT Id, ContentDocumentId FROM ContentVersion WHERE Id = :contentVersion.Id WITH SYSTEM_MODE LIMIT 1];
    system.debug('currentContentVersion: ' + currentContentVersion);

    ContentDocumentLink contentDocumentLink = new ContentDocumentLink();
    contentDocumentLink.LinkedEntityId = linkedEntityId;
    contentDocumentLink.ContentDocumentId = currentContentVersion.ContentDocumentId;
    contentDocumentLink.ShareType = 'V'; 
    contentDocumentLink.Visibility = 'AllUsers'; 
    system.debug('contentDocumentLink: ' + contentDocumentLink);
    
    insert contentDocumentLink;
    return currentContentVersion;
}

private static Id getExternalDataSourceId(String name) {
    ExternalDataSource externalDataSource = [SELECT Id FROM ExternalDataSource WHERE DeveloperName = :name LIMIT 1];
    return externalDataSource.Id;
}
```
#### createContentVersionFromGoogleFile
こちらはGoogleDriveからの戻り値をもとに、ContentVersionとContentDocumentLinkを作成します。通常のContentVersionとは異なり以下の点が必要です。
- `ContentLocation` ='E' // Externalの意味
- `ExternalDocumentInfo1` // 外部ファイルのViewアドレス
- `ExternalDocumentInfo2` // file:Id情報
- `ExternalDataSourceId` // External Data Sourceに登録してある外部ソースのId

#### externalDataSourceId
ここで最重要なのが、`externalDataSourceId`です。こちらは前回FilesConnectで作成した`External Data Source`のGoogle Driveは実はIdが設定されていて、これをContentVersionの`ExternalDataSourceId` に設定する必要があります。`getExternalDataSourceId`のパラメータに`GoogleDrive`と入れて取得してきます。
参照：[8. Google ドライブのExternal Data Sourceの定義](https://qiita.com/masahiro_sf/items/d4cc1bfe3ffe6f75f0ed#8-google-%E3%83%89%E3%83%A9%E3%82%A4%E3%83%96%E3%81%AEexternal-data-source%E3%81%AE%E5%AE%9A%E7%BE%A9)
</details>

### PromiseAllが完了したら呼ぶメソッド
上記で作成したメソッドを呼び出しパラメータを渡します。

<details open><summary>getExternalDataSourceId</summary>

```apex
/* 略 */
contentVersion.ExternalDataSourceId = getExternalDataSourceId(dataSourceName); // 重要
/* 略 */
private static Id getExternalDataSourceId(String name) {
    ExternalDataSource externalDataSource = [SELECT Id FROM ExternalDataSource WHERE DeveloperName = :name LIMIT 1];
    return externalDataSource.Id;
}
```
</details>

<details open><summary>createContentVersionFromGoogleFile</summary>

```javascript
handleUpload() {
    this.isUploading = true;
    Promise.all(this.files.map(file => this.uploadFile(file)))
        .then((result) => {
            console.log('result: ', result);
            const createContentVersionPromises = result.map(file => this.createContentVersion(file));
            return Promise.all(createContentVersionPromises);
        })
        .catch(error => {
            console.error('Error uploading files: ', error);
            this.dispatchEvent(
                new ShowToastEvent({
                    title: 'Error uploading files',
                    message: error.body.message,
                    variant: 'error'
                })
            );
        })
        .finally(() => {
            this.finishUploading();
        });
}
/*
(省略)
*/
createContentVersion(file) {
    const googleFileInfo = {
        'id': file.id,
        'name': file.name,
        'mimeType': file.mimeType
    };
    const params = {
        googleFileInfo: googleFileInfo,
        linkedEntityId: this.recordId,
        dataSourceName: 'GoogleDrive'
    };
    return createContentVersionFromGoogleFile(params);
}
```

Google Driveから帰ってきた戻り値を`id`, `name`, `mimeType`のオブジェクトとしてまとめます。追加でLinkedEntityIdは、ファイルが紐づくsObjectを表すため`recordId`です。
最後は`dataSourceName`ですが、'GoogleDrive'をパラメータで指定することによって、Apex内でprivate メソッドによる組織のExternalDataSourceで、名前が'GoogleDrive'のIdを取得してくれます。
</details>

## Files追加後の関連リスト自動更新

![content-document-link-with-refresh.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/6757b969-b7d2-23df-3318-ebb426fb1986.gif)


<details open><summary>ContentDocumentLink追加後の関連リスト自動更新</summary>

また、ContentDocumentLinkが追加されたのち、関連リストを自動更新するために `lightning/refresh` をimportしておき、finishUploadingで呼びましょう。そうすると、追加された関連FilesがGoogle DriveへのReferenceとして追加されたものを反映したリストになります。

```javascript
import { RefreshEvent } from "lightning/refresh";
/*
(省略)
*/
finishUploading() {
    this.isUploading = false;
    this.files = [];
    this.selectedFiles = [];
    this.dispatchEvent(
        new ShowToastEvent({
            title: 'Success',
            message: 'Files uploaded successfully',
            variant: 'success'
        })
    );
    this.dispatchEvent(new RefreshEvent());
}
```
</details>

ここまでできればjs側のファイルの準備は完了です。

# Folder作成
fileをDevフォルダにアップロードするのはできましたが、より整理されたフォルダ管理をしたいので、オブジェクト毎でまず大枠をフォルダで括り、その中に個別のフォルダを作成し、ファイルアップロードを行えれば、外部共有などでも安全にスコープを限定できます。

例：`Dev` > `Account` > `United Oil & Gas Corp.` のような構造です。
![folder-directory.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/33301a28-9b52-b4bc-ba57-daf9c5c816fb.png)

`United Oil & Gas Corp.`を子フォルダとすると、`Account`が親フォルダになるという構造で整理していきます。
## フォルダ作成手順
1. `United Oil & Gas Corp.`というAcountのレコードページにボタンを配置
1. Google Driveに同会社名のフォルダが`Account`配下にあるか探す
    - あれば、そのフォルダへファイルをアップロード
    - なければ、`Account`配下に`United Oil & Gas Corp.`のフォルダを作成
        - そのフォルダにファイルをアップロードする、というロジックを組みます。

### フォルダの新規作成ボタン
<details open><summary>Create Google Drive Folder</summary>

```html
<lightning-button lwc:if={isBlankFolder} label="Create Google Drive Folder"
onclick={upsertFolder} class={buttonUploadClass}></lightning-button>
<template lwc:if={isCreating}>
    <lightning-spinner alternative-text="Creating..." size="x-small"></lightning-spinner>
</template>
```
</details>

### フォルダを新規作成または更新
<details open><summary>upsertFolder</summary>

```apex
@AuraEnabled
public static String upsertFolder(String folderName, String parentFolderName) {
    String parentFolderId = getFolderMetadata(parentFolderName).Folder_Id__c;   
    String folderId = findFolder(folderName, parentFolderId);   
    if (folderId != null) {
        return folderId; 
    }  
    return createFolder(folderName, parentFolderId);
}
```
</details>


### フォルダを探す
<details open><summary>findFolder</summary>

```apex
public static String findFolder(String folderName, String parentFolderId) {
        String query = 'name = \'' + folderName + '\' and mimeType = \'application/vnd.google-apps.folder\' and trashed = false and \'' + parentFolderId + '\' in parents';
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:GoogleDrive/files?q=' + EncodingUtil.urlEncode(query, 'UTF-8'));
        req.setMethod('GET');
        req.setHeader('Content-Type', 'application/json');
    
        Http http = new Http();
        HttpResponse res = http.send(req);

    if (res.getStatusCode() == 200) {
        Map<String, Object> jsonResponse = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
        List<Object> files = (List<Object>) jsonResponse.get('files');
        if (!files.isEmpty()) {
            return (String)((Map<String, Object>) files[0]).get('id');
        }
    } else {
        System.debug('Error finding folder: ' + res.getBody());
    }
    return null; // Folder not found
}
```
</details>


### フォルダを新規作成
<details open><summary>createFolder</summary>

```apex
public static String createFolder(String folderName, String parentFolderId) {
    Map<String, Object> requestBody = new Map<String, Object>{
        'name' => folderName,
        'mimeType' => 'application/vnd.google-apps.folder',
        'parents' => new List<String>{ parentFolderId }
    };

    HttpRequest req = new HttpRequest();
    req.setEndpoint('callout:GoogleDrive/files');
    req.setMethod('POST');
    req.setHeader('Content-Type', 'application/json');
    req.setBody(JSON.serialize(requestBody));

    Http http = new Http();
    HttpResponse res = http.send(req);

    if (res.getStatusCode() == 200 || res.getStatusCode() == 201) {
        Map<String, Object> jsonResponse = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
        return (String) jsonResponse.get('id'); 
    } else {
        System.debug('Error creating folder: ' + res.getBody());
    }
    return null; // Folder creation failed
}
```
</details>

## parentFolderIdをCustom MetaDataで管理
上記のメソッドはともにparentFolderが何であるかを伝えてそこに格納するのですが、parentFolderIdをベタ打ちするもは避けたいので、Custom Metadataで管理します。LWCをAccountに配置すれば、Google DriveのAccountフォルダをparentFolderIdに、Opportunityに配置すればGoogle DriveのOpportunityフォルダをparentFolderIdに、という具合です。

### Custom Metadataの作成
Setup > Custom Metadata Types > New へ行き、以下のように設定する。
![custom-metadata.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/c0e8fe33-1101-c6c1-3f07-e5f9fb7cbbf6.png)
| Field | Value |
|:-|:-|
| Label | GoogleDrive (任意） |
| Object Name  | GoogleDrive (任意） |
| Description | 説明 (任意）  |
| Visibility  | All Apex code and APIs can use the types, and its visible in Setup|

### Filder Id 項目を作成
Folder_Id__cという項目を追加する。これとDeveloperNameを後述のApexで利用する。
![folder_id_field.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/a05dbac4-a665-493b-a532-1b1ad531771c.png)

### Google DriveのAcount フォルダのIdを取得
Dev > Accountと行くとURLにfolders/以下の文字列がfolder Idです。
![foldier-Id-google.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/b022f312-8729-c88c-f5fc-810aa631caea.png)
AccountというDeveloperNameに、Google DriveのフォルダIdを格納しておきます。
![create-account-folder-id.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/31971a0c-5155-51be-4168-fd29a5a9f888.png)

これで、Google DriveのAccountフォルダId（parentFolderId）は、以下のapexでDeveloperNameで取得できようになります。

<details open><summary>getFolderMetadata</summary>

```apex
@AuraEnabled
public static GoogleDrive__mdt getFolderMetadata(String parentFolderName) {
    // Query to get up to 200 metadata records
    GoogleDrive__mdt folderMetadata = [SELECT DeveloperName, Folder_Id__c FROM GoogleDrive__mdt WHERE DeveloperName =:parentFolderName LIMIT 1];
    return folderMetadata;
}
```
</details>


## driveFolderIdの動的処理
各SObjectに`Drive_Folder_Id__c`と`Drive_Folder_Name__c`項目を作成して、一度作成したGoogle Driveのフォルダ名、フォルダIdを格納しておきます。すでに作成されていれば`@wire`で取得してきますし、なければ`Create Drive Folder`ボタンを押すことで`upsertFolder`が呼ばれ、新規のフォルダをGoogle Driveで作成したのちupdateRecordで、該当レコード内の`Drive_Folder_Id__c`と`Drive_Folder_Name__c`項目に更新しておきます。

<details open><summary>js</summary>

```javascript
import { getRecord, updateRecord } from 'lightning/uiRecordApi';

import ACCOUNT_UNIQUE_NUMBER_FIELD from '@salesforce/schema/Account.AccountNumber';
import ACCOUNT_NAME_FIELD from '@salesforce/schema/Account.Name';
import ACCOUNT_DRIVE_FOLDER_ID_FIELD from '@salesforce/schema/Account.Drive_Folder_Id__c';
import ACCOUNT_DRIVE_FOLDER_NAME_FIELD from '@salesforce/schema/Account.Drive_Folder_Name__c';
/*
略
*/
get fields() {
    return [
        ACCOUNT_UNIQUE_NUMBER_FIELD,
        ACCOUNT_NAME_FIELD,
        ACCOUNT_DRIVE_FOLDER_ID_FIELD,
        ACCOUNT_DRIVE_FOLDER_NAME_FIELD
    ];
}
    
@wire(getRecord, { recordId: '$recordId', fields: '$fields' })
wiredRecord({ error, data }) {
    if (data) {
        this.uniqueNumber = data.fields[ACCOUNT_UNIQUE_NUMBER_FIELD.fieldApiName].value;
        this.uniqueName = data.fields[ACCOUNT_NAME_FIELD.fieldApiName].value;
        this.driveFolderName = data.fields[ACCOUNT_DRIVE_FOLDER_NAME_FIELD.fieldApiName].value;
        this.driveFolderId = data.fields[ACCOUNT_DRIVE_FOLDER_ID_FIELD.fieldApiName].value;
        this.driveFolderUrl = `https://drive.google.com/drive/folders/${this.driveFolderId}`;
        this.copyAvailable = true;
        this.copyIconVariant = 'utility:copy';
    }
    if (error) {
        console.error('Error loading record:', error);
    }
}

```
### wire
`@wire`でコンポーネントを配置したレコードの情報を動的に取得します。`Drive_Folder_Id__c`があれば、それを`this.driveFolderId`に当てて、なければ、新規作成のボタンを表示にします。新規作成ボタンを押せば、Google Driveに一意のフォルダが作成され戻り値を`Drive_Folder_Id__c`に格納します。
</details>

# 注意点
今回カバーしきれていない点をいかにまとめておきます。今後徐々に対応したバージョンをおりを見て投稿していきます。
### 他のオブジェクトで利用する場合
Opportunity, Caseそれぞれのフォルダを作成する場合、Custom MetadataにそれぞれのFolderIdを作成しておきましょう。DeveloperNameがObjectApiNameと一致するように、特にカスタムオブジェクトは__cが必要です。
### objectApiNameでの条件分岐
また、それぞれのオブジェクトにDrive_Folder_Id__cとDrive_Folder_Name__cを作成する必要があります。upsertFolderでもwireでも、importとそれぞれのobjectApiNameによる条件分岐が必要です。
### 共有フォルダのアクセス権限設定
共有フォルダへのアクセスはGoogle Driveのフォルダレベルで管理
### ファイル削除の際の連動
ファイルをSalesforce側で削除した際、GoogleDriveのフォルダ内のファイルも削除するかどうか　（一貫性を保つためには削除した方が良い、fileIdがあるので一意性は保たれる）
### 外部共有の仕方
あくまで`Reference`なのでメールでの添付などで外部と共有するのはやはり実物をLoadするか、共有ファイルのリンクを共有する必要がある
### 比較的大きいファイルのアップロード
4Mを超えるファイルのアップロードは、Google Drive APIのresumableを利用する必要があります
参照: [uploadType=resumable](https://developers.google.com/drive/api/guides/manage-uploads#resumable)
### バージョン管理
Google Drive APIは現在v3ですが、今後バージョンが上がって新規機能や設定変更など常にアップデート情報を得て定期的にメンテする必要

# 参考
[Develop Google Drive solutions](https://developers.google.com/drive/api/guides/about-files)

# 最後に
後ほどgithubに上げます。好評であれば来年はPackageで配布するのに挑戦したいと思います。質問があればxからお気軽にどうぞ。
[@masahiro_sf](https://x.com/@masahiro_sf)