0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SalesforceAdvent Calendar 2024

Day 20

Google DriveとSalesforceの相互連携

Last updated at Posted at 2024-12-20

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

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

upsert-folder-document-link.gif

ファイルまとめ

class
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;
    }
}
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>
js
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);
        }
    }
}
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>
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
  );
}

新規項目まとめ

New Fields
Account.Drive_Folder_Id__c
Account.Drive_Folder_Name__c
Custom Metadata
Custom Metadata Type:
GoogleDrive

Custom Metadata Field:
Folder_Id__c

手順

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

Classの作成

まずGoogleDriveController classを作成してみます。その中で、uploadFileToGoogleDriveのメソッドを作成します。

ApexでuploadFileToGoogleDriveのメソッドの作成

uploadFileToGoogleDrive
@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());
    }
}

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

  • makeUploadRequest
  • getFileExtension
  • getMineType
  • createFileMetadata
  • createMultipartBody
  • handleUploadResponse
makeUploadRequest
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用)

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

参照: uploadType=media
参照: uploadType=multipart
参照: uploadType=resumable

getFileExtension
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();
}

getMimeType
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;
}
createFileMetadata
private static String createFileMetadata(String fileName, String mimeType, String folderId) {
    return '{"name": "' + fileName + 
           '", "mimeType": "' + mimeType + 
           '", "parents": ["' + folderId + '"]}';
}
createMultipartBody
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');
}
handleUploadResponse
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());
    }
}

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

アップロードの検証

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

検証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

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

recent-text.png

folderIdありで検証

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

folder-dev-id.png

検証Apex (folderIdあり)
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

実行後、Devフォルダ配下にファイルが作成されていれば、GoogleDriveController.uploadFileToGoogleDriveのメソッドの検証は成功です。
apex-with-folder-Id-finished.png

LWCを作成

以上でControllerの基本的fileUploadメソッドの作成は終わりました。それでは続いてLWCで以下の単純なボタンのみあるコンポートネントを作成します。

  • ファイルをアップロードするアクション (lightning-input)
  • Googleへアップロードするボタン (lightning-button)

lwc-googledrive-component.png

構成

htmlファイルの作成

構成はとりあえず最低限で lightning-inputlightning-buttondivで囲い slds-gridで縦方向中央揃えします。

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 / lightning-input / lightning-button / slds-grid

jsファイルの作成

今はほぼ空で。次の工程で複数のメソッドを追加していきます。

js
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を有効にしてrecordIdflexipageRegionWidthを取得します。どちらも後の工程で利用します。

import method

前工程で作成してあるuploadFileToGoogleDriveメソッドもimportしておきましょう。

import toast

またお馴染のShowToastEventも処理完了の通知で利用するのでimportしておきましょう。

js-meta.xml

exposedをtrueにするのとでlightning__RecordPageのみ追加します。これでレコードページにLWCを配置できるようになります。

js-meta.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>

添付ファイルの表示(onremoveアクションあり)

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

files表示
<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 / lightning-spinner

Upload Filesボタンのアクションを追加

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

jsにhandleFileChangeとgetFileIconを追加
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';
    }
}

handleFileChange

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

getFileIcon

linghting-inputで渡されるデータは基本的に以下のようになります。file-console-log.png

key
lastModified
lastModifiedDate
name
size
type (こちらを使用)
webkitRelativePath

lightning-iconに渡してslds iconsのdoctypeで使用可能な文字列に変換しておきます。メジャーなものだけを追加したので必要に応じてcaseを増やしてみてください。
slds doctype
slds-icons-doctype.png

Upload to Googleボタンを有効化する

filesが0以上の時にのみUpload to Googleを有効化します。

isUploadDisabledのgetterを設定
get isUploadDisabled() {
  return this.files.length === 0;
}
lightning-button disabledを設定
<lightning-button disabled={isUploadDisabled} label="Upload to Google" 
variant="label-hidden" class="slds-m-left_x-small" onclick={handleUpload}>
</lightning-button>

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

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

remove-pill.gif

onremoveのhandleRemoveFileアクションを追加
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

Upload to Google ボタンのアクションを追加

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

upload-to-google-demo.gif

handleUploadによるfilesの処理
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'
        })
    );
}

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もあると明示的にアップロードの終了がわかるのでユーザーにとって丁寧な通知になります。

base64について

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

参照: JavaScriptでBase64エンコード・デコード
参照: base64ってなんぞ??理解のために実装してみた

JavaScriptにおけるbase64へのencodingの動き
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 オブジェクトを作成
  2. reader.readAsDataURL(file) メソッドを呼び出すと、ファイルが base64 エンコードされたデータ URL に変換
  3. 変換が完了すると onload イベントが発生
  4. reader.result には "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD..." のようなフォーマットの文字列が含まれる
  5. split(',')[1] で base64 部分(/9j/4AAQSkZJRgABAQEAYABgAAD...)だけを抽出
  6. resolve() メソッドで Promise を解決し、base64 文字列を返す

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

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

Filesに追加

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

戻り値を確認する

複数のファイルをアップロードすれば複数の戻り値が配列で戻ってきます。これを利用して次でContentDocumentLinkを作成します。

uploadFileの戻り値の確認
handleUpload() {
    this.isUploading = true;
    Promise.all(this.files.map(file => this.uploadFile(file)))
        .then((result) => {
            console.log('result: ', result);
        })
        .finally(() => {
            this.finishUploading();
        });
}
uploadFileの戻り値
[
    {
        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メソッドへ投げ込みます。

ApexでContentVersionとDocumentLinkを作成

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

Apex (ContentVersionとDocumentLink)
@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の定義

PromiseAllが完了したら呼ぶメソッド

上記で作成したメソッドを呼び出しパラメータを渡します。

getExternalDataSourceId
/* 略 */
contentVersion.ExternalDataSourceId = getExternalDataSourceId(dataSourceName); // 重要
/* 略 */
private static Id getExternalDataSourceId(String name) {
    ExternalDataSource externalDataSource = [SELECT Id FROM ExternalDataSource WHERE DeveloperName = :name LIMIT 1];
    return externalDataSource.Id;
}
createContentVersionFromGoogleFile
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を取得してくれます。

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

content-document-link-with-refresh.gif

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

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

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());
}

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

Folder作成

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

例:Dev > Account > United Oil & Gas Corp. のような構造です。
folder-directory.png

United Oil & Gas Corp.を子フォルダとすると、Accountが親フォルダになるという構造で整理していきます。

フォルダ作成手順

  1. United Oil & Gas Corp.というAcountのレコードページにボタンを配置
  2. Google Driveに同会社名のフォルダがAccount配下にあるか探す
    • あれば、そのフォルダへファイルをアップロード
    • なければ、Account配下にUnited Oil & Gas Corp.のフォルダを作成
      • そのフォルダにファイルをアップロードする、というロジックを組みます。

フォルダの新規作成ボタン

Create Google Drive Folder
<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>

フォルダを新規作成または更新

upsertFolder
@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);
}

フォルダを探す

findFolder
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
}

フォルダを新規作成

createFolder
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
}

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

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

Google DriveのAcount フォルダのIdを取得

Dev > Accountと行くとURLにfolders/以下の文字列がfolder Idです。
foldier-Id-google.png
AccountというDeveloperNameに、Google DriveのフォルダIdを格納しておきます。
create-account-folder-id.png

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

getFolderMetadata
@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;
}

driveFolderIdの動的処理

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

js
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に格納します。

注意点

今回カバーしきれていない点をいかにまとめておきます。今後徐々に対応したバージョンをおりを見て投稿していきます。

他のオブジェクトで利用する場合

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

バージョン管理

Google Drive APIは現在v3ですが、今後バージョンが上がって新規機能や設定変更など常にアップデート情報を得て定期的にメンテする必要

参考

Develop Google Drive solutions

最後に

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?