Unityエディタでスプレッドシートをエクセルとしてダウンロード

2022/12/16 : 初稿
Unity : 2021.3.15f1

作ってるゲームのマスターデータをスプレッドシートで管理したいけれど、
スプレッドシートをGAS使って解析するのはしんどいし
２分以内に終わらないと打ち切られてしまう。

ならば、エクセルとしてダウンロードしてから、
ローカルでNPOIとか使って解析すればいいじゃん的な。
# やりたいこと
`https://drive.google.com/drive/folders/うんたらかんたら`
にあるスプレッドシートを全て
Unityのプロジェクトフォルダ直下にある`Excels`フォルダに
ダウンロードしたいとします。

## スプレッドシートをエクセルとしてダウンロードするUnity側のスクリプト
こいつをAssets/Editorとかに置きます。
また、Unityのプロジェクト直下に`Excels`フォルダを掘ります。

```css:Assets/Editor/MasterDownloader.cs
///
/// @file   MasterDownloader.cs
/// @author KYukimoto
/// @date   
///
/// @brief  スプレッドシートをエクセルとしてダウンロード
/// 
using Google.Apis.Auth.OAuth2;
using Google.Apis.Services;
using Google.Apis.Util.Store;
using Google.Apis.Download;
using Google.Apis.Drive.v3;
using System;
using System.IO;
using System.Threading;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

namespace Utils
{
    // サンプル
    class SampleMasterDownloader : MasterDownloader
    {
        // GoogleAPI : Google Developer Consoleで作成したプロジェクト名
        protected override string GoogleApplicationName { get { return "ExcelDownloader"; } }

        // マスター置き場のフォルダId : GoogleDriveをブラウザで開いた時のURL https://drive.google.com/drive/folders/うんたらかんたら　のウンタラカンタラ部分
        protected override string FolderId { get { return "XXXXXXXXXXXXXXXXXXXXXXXXXX"; } }

        // ダウンロードエントリポイント
        [MenuItem("Utils/マスターデータ/スプレッドシート/エクセルとしてダウンロード")]
        static void DownloadExcels()
        {
            SampleMasterDownloader instance = new SampleMasterDownloader();
            instance.Download();
        }

        // アップロードエントリポイント
        [MenuItem("Utils/マスターデータ/スプレッドシート/エクセルをアップロード")]
        static void UploadExcels()
        {
            SampleMasterDownloader instance = new SampleMasterDownloader();
            instance.Upload();
        }
    }

    // MasterDownloader
    abstract class MasterDownloader
    {
        // GoogleAPI : Google Developer Consoleで作成したプロジェクト名
        protected abstract string GoogleApplicationName { get; }

        // マスター置き場のフォルダId : GoogleDriveをブラウザで開いた時のURL https://drive.google.com/drive/folders/うんたらかんたら　のウンタラカンタラ部分
        protected abstract string FolderId { get; }
            
        // エクセル置き場
        protected virtual string ExcelsPath { get { return "Excels"; } }
        protected virtual string TimeStampsPath { get { return "SpreadSheets"; } }

        // client_secret.jsonの置き場所
        protected virtual string ClientSecretJsonPath
        {
            get
            {
                // デフォルトはこのソースファイル(MasterDownloader.cs)と同じフォルダ
                var stackFrame = new System.Diagnostics.StackFrame(true);
                var thisFilePath = stackFrame.GetFileName();
                var thisDir = Path.GetDirectoryName(thisFilePath);
                var client_secret_path = Path.Combine(thisDir, "client_secret.json");
                return client_secret_path;
            }
        }

        // 認証結果保存場所
        protected virtual string StoreCredentialsPath
        {
            get
            {
                // デフォルトはこのソースファイル(MasterDownloader.cs)と同じフォルダ
                var stackFrame = new System.Diagnostics.StackFrame(true);
                var thisFilePath = stackFrame.GetFileName();
                var thisDir = Path.GetDirectoryName(thisFilePath);
                var credPath = Path.Combine(thisDir, "credentials");
                return credPath;
            }
        }

        // スプレッドシートをエクセルとしてダウンロード
        (string desiredPath, string savedPath) DownloadExcel(DriveService service, string excelsPath, string googleDriveFileID, string fileName)
        {
            // ダウンロードファイル名
            var desiredPath = excelsPath + "/" + fileName + ".xlsx";
            var savedPath = excelsPath + "/__" + fileName + ".xlsx";

            // スプレッドシートをエクセルに変換
            var fileId = googleDriveFileID;
            var request = service.Files.Export(fileId, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");

            // ダウンロード
            var stream = new MemoryStream();
            request.MediaDownloader.ProgressChanged += (IDownloadProgress progress) => {
                switch (progress.Status) {
                case DownloadStatus.Downloading:
                    Debug.Log(progress.BytesDownloaded + "byteダウンロードしました...");
                    break;
                case DownloadStatus.Completed:
                    File.Delete(savedPath);
                    var fileStream = new FileStream(savedPath, FileMode.Create);
                    stream.WriteTo(fileStream);
                    fileStream.Close();
                    Debug.Log("スプレッドシートのダウンロード完了.");
                    break;
                case DownloadStatus.Failed:
                    Debug.Log("スプレッドシートのダウンロードに失敗しました.");
                    break;
                }
            };
            var result = request.DownloadWithStatus(stream);

            // 成否を返す
            return (result.Status == DownloadStatus.Completed) ? (desiredPath, savedPath) : (null, null);
        }

        // ダウンロード処理エントリポイント
        protected void Download()
        {
            // 確認
            var ret = EditorUtility.DisplayDialogComplex("確認", "GoogleDriveからスプレッドシートをダウンロードします", "差分のみ", "キャンセル", "フル");
            if (ret == 1) {
                return;
            }
            var full = ret == 2;

            // 処理中...
            EditorUtility.DisplayProgressBar("処理中", "GoogleAPIを設定しています...", 0);

            // 処理
            var downloadedPaths = new Dictionary<string, string>();
            try {
                DownloadCore(downloadedPaths, full);
            } catch (Exception e) {
                Debug.LogError("エラー:" + e.ToString());
                EditorUtility.DisplayDialog("失敗", "失敗しました", "OK");
            }
            foreach (var downloadedPath in downloadedPaths) {
                var savedPath = downloadedPath.Key;
                File.Delete(savedPath);
            }

            // 終わり
            EditorUtility.ClearProgressBar();
        }

        // ダウンロードした日付
        [Serializable]
        class TimeStamp
        {
            public string FileName;
            public string ModifiedTime;
        }

        // ダウンロードした日付リスト
        [Serializable]
        class TimeStamps
        {
            public List<TimeStamp> Stamps;
        }

        // ダウンロード実体
        bool DownloadCore(Dictionary<string, string> downloadedPaths, bool full)
        {
            // DriveAPIサービス取得
            var service = SetupServie();

            // ダウンロードしたファイルの置き場所
            string excelsPath = Path.GetDirectoryName(Application.dataPath) + "/" + ExcelsPath;

            // そのフォルダがなければ生成
            if (!Directory.Exists(excelsPath)) {
                Directory.CreateDirectory(excelsPath);
                full = true;
            }

            // タイムスタンプ
            var stampsDir = Path.GetDirectoryName(Application.dataPath) + "/" + TimeStampsPath;
            var stampsPath = stampsDir + "/TimeStamps.json";
            TimeStamps savedStamps = null;
            if (!full && File.Exists(stampsPath)) {
                try {
                    var json = File.ReadAllText(stampsPath);
                    if (json != null) {
                        savedStamps = JsonUtility.FromJson<TimeStamps>(json);
                    }
                } catch {
                    Debug.LogWarning("ダウンロード履歴がありません");
                }
            }

            // format
            var format = "yyyy-MM-ddTHH:mm:ss zzz";

            // フォルダ内のファイル一覧取得
            FilesResource.ListRequest listRequest = service.Files.List();
            listRequest.PageSize = 1000;
            listRequest.Fields = "nextPageToken, files(id, name, modifiedTime)";
            listRequest.Q = string.Format("'{0}' in parents and trashed=false and mimeType='application/vnd.google-apps.spreadsheet'", FolderId);
            var files = listRequest.Execute().Files;
            var saveStamps = new TimeStamps();
            saveStamps.Stamps = new List<TimeStamp>();
            if (files != null && files.Count > 0) {
                for (int i = 0; i < files.Count; i++) {
                    var file = files[i];
                    var saveStamp = new TimeStamp();
                    saveStamp.FileName = file.Name;
                    saveStamp.ModifiedTime = file.ModifiedTime.Value.ToString(format);
                    saveStamps.Stamps.Add(saveStamp);
                    if (savedStamps != null && savedStamps.Stamps != null) {
                        var savedStamp = savedStamps.Stamps.Find((s) => s.FileName == file.Name);
                        if (savedStamp != null && savedStamp.ModifiedTime != null) {
                            var savedDT = DateTime.ParseExact(savedStamp.ModifiedTime, format, System.Globalization.CultureInfo.CurrentCulture);
                            var saveDT = DateTime.ParseExact(saveStamp.ModifiedTime, format, System.Globalization.CultureInfo.CurrentCulture);
                            if (savedDT >= saveDT) {
                                continue;
                            }
                        }
                    }
                    EditorUtility.DisplayProgressBar("処理中", file.Name + "をダウンロードしています...", (float)i / files.Count);
                    Debug.Log("スプレッドシートをダウンロードします:" + file.Name + " id:" + file.Id);
                    var (desiredPath, savedPath) = DownloadExcel(service, excelsPath, file.Id, file.Name);
                    if (savedPath == null) {
                        Debug.LogError("ダウンロード失敗");
                        return false;
                    }
                    downloadedPaths.Add(savedPath, desiredPath);
                    Debug.Log("Add key:" + savedPath + " value:" + desiredPath);
                }
            }
            Debug.Log("ダウンロード成功。ファイル数:" + downloadedPaths.Count);

            // ファイルをリネーム
            foreach (var downloadedPath in downloadedPaths) {
                var savedPath = downloadedPath.Key;
                var desiredPath = downloadedPath.Value;
                File.Copy(savedPath, desiredPath, true);
            }

            // タイムスタンプを保存
            var saveStampJson = JsonUtility.ToJson(saveStamps);
            try {
                if (!Directory.Exists(stampsDir)) {
                    Directory.CreateDirectory(stampsDir);
                }
                File.WriteAllText(stampsPath, saveStampJson, System.Text.Encoding.UTF8);
            } catch {
                Debug.LogWarning("ダウンロード履歴の保存に失敗しました");
            }

            // 成功
            return true;
        }

        // Upload
        protected void Upload()
        {
            // 確認
            if (!EditorUtility.DisplayDialog("確認", "GoogleDriveにスプレッドシートをアップロードします", "Yes", "No")) {
                return;
            }
            if (!EditorUtility.DisplayDialog("確認", "GoogleDriveにある既存のスプレッドシートは削除され、新しいファイルに差し変わりますがよろしいですか？", "Yes", "No")) {
                return;
            }

            // 処理中...
            EditorUtility.DisplayProgressBar("処理中", "GoogleAPIを設定しています...", 0);

            // 処理
            try {
                // ダウンロードしたファイルの置き場所
                string excelsPath = Path.GetDirectoryName(Application.dataPath) + "/" + ExcelsPath;

                // エクセルリスト取得
                var excelPaths = Directory.GetFiles(excelsPath, "*.xlsx");

                // サービス取得
                var service = SetupServie();

                // 同名ファイルを削除
                DeleteCore(service, excelPaths);

                // アップロード
                UploadCore(service, excelPaths);
            } catch {
                EditorUtility.DisplayDialog("失敗", "失敗しました", "OK");
            }

            // 終わり
            EditorUtility.ClearProgressBar();
        }

        // GoogleDriveサービスのセットアップ
        DriveService SetupServie()
        {
            UserCredential credential;

            // GoogleAPI設定
            string[] Scopes = { DriveService.Scope.Drive, };
            using (var stream0 = new FileStream(ClientSecretJsonPath, FileMode.Open, FileAccess.Read)) {
                var credPath = Path.Combine(StoreCredentialsPath, "credentials");
                credential = GoogleWebAuthorizationBroker.AuthorizeAsync(
                    GoogleClientSecrets.FromStream(stream0).Secrets,
                    Scopes,
                    "user",
                    CancellationToken.None,
                    new FileDataStore(credPath, true)).Result;
                Debug.Log("Credential file saved to: " + credPath);
            }

            // DriveAPIサービス取得
            var service = new DriveService(new BaseClientService.Initializer()
            {
                HttpClientInitializer = credential,
                ApplicationName = GoogleApplicationName,
            });

            // サービスを返す
            return service;
        }

        // アップロード実体
        bool UploadCore(DriveService service, string[] excelPaths)
        {
            // アップロード
            for (int i = 0; i < excelPaths.Length; i++) {
                var excelPath = excelPaths[i];

                // アップロード
                {
                    Google.Apis.Upload.IUploadProgress prog;
                    FileStream fsu = new FileStream(excelPath, FileMode.Open);
                    try {
                        Google.Apis.Drive.v3.Data.File meta = new Google.Apis.Drive.v3.Data.File();
                        meta.Name = Path.GetFileNameWithoutExtension(excelPath);
                        meta.MimeType = "application/vnd.google-apps.spreadsheet";
                        meta.Parents = new List<string>() { FolderId, };

                        EditorUtility.DisplayProgressBar("処理中", meta.Name + "をアップロードしています...", (float)i / excelPaths.Length);
                        FilesResource.CreateMediaUpload req = service.Files.Create(meta, fsu, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
                        req.Fields = "id, name";
                        prog = req.Upload();
                    } finally {
                        fsu.Close();
                    }
                }
            }

            // 成功
            return true;
        }

        // Delete
        bool DeleteCore(DriveService service, string[] excelPaths)
        {
            // フォルダ内のファイル一覧取得
            FilesResource.ListRequest listRequest = service.Files.List();
            listRequest.PageSize = 1000;
            listRequest.Fields = "nextPageToken, files(id, name)";
            listRequest.Q = string.Format("'{0}' in parents and trashed=false and mimeType='application/vnd.google-apps.spreadsheet'", FolderId);
            var files = listRequest.Execute().Files;
            if (files != null && files.Count > 0) {
                for (int i = 0; i < files.Count; i++) {
                    var file = files[i];
                    bool shouldDelete = false;
                    foreach (var excelPath in excelPaths) {
                        if (Path.GetFileNameWithoutExtension(excelPath) == file.Name) {
                            shouldDelete = true;
                            break;
                        }
                    }
                    if (!shouldDelete) {
                        continue;
                    }
                    EditorUtility.DisplayProgressBar("処理中", "同名ファイル" + file.Name + "を削除しています...", (float)i / files.Count);
                    Debug.Log("スプレッドシートを削除します:" + file.Name + " id:" + file.Id);
                    var request = service.Files.Delete(file.Id);
                    request.Execute();
                }
            }

            // 成功
            return true;
        }
    }
}

```
あとは`SampleMasterDownloader`クラスの
`GoogleApplicationName`と`FolderId`を
自分の環境に合わせて書き換えます。

これでUnityのメニューに「Utils」が追加され、
実行できてめでたしめでたし...とは行かない。
その前にアクセス認証などの事前準備を行う必要があります。

# 事前準備リスト

1. GoogleDriveにプログラムからアクセス可能にする
1. UnityにGoogleAPI呼び出しdllをインポート

## GoogleDriveにプログラムからアクセス可能にする

1. ブラウザで<a href="https://console.developers.google.com/">Google Developer Console</a>にアクセス
1. プロジェクトを作成。
下記ではプロジェクト名を
`SampleMasterDownloader.GoogleApplicationName`
に合わせて`ExcelDownloader`としていま...スペルミスしてるぅ
![createproj.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3072360/c7fadfbe-0fdf-0b39-cf5a-acd7aba38a8d.png)
1. OAuth同意画面作成
    - 最初の画面、UserTypeは外部。
    - 二番目の画面
        - アプリ名はここでは`ExcelDownloader`
        - ユーザーサポートメールとデベロッパーの連絡先は自分のメアド
        - その他は空欄のまま
    - 三番目の画面（スコープ）、何もいじらず次へ。
    - 四番目の画面（テストユーザー）、何もいじらず次へ。
    - 最後の画面で一番下にある`ダッシュボードへ戻る`をクリック。
1. DriveAPIとSheetsAPIを有効化
![enableapi.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3072360/19bfd223-7bb2-31b2-59a1-36d38b47adc1.png)
この辺の画面から`＋APIとサービスの有効化`をクリックし、
その後の画面で`Drive`と`Sheets`を検索して有効化します。

1. 認証設定をしてclient_secret.jsonをダウンロードし、MasterDownloader.csと同じフォルダに配置
![auth.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3072360/80250e66-319a-a9f8-27ef-16c673499504.png)
`+認証情報を作成`をクリックしてOAuthクライアントIDを作成。<br>
その後に出てくる画面は適当に。
アプリケーションの種類は「デスクトップアプリ」でいけそう。
作成したらjsonをダウンロードするボタンがどっかにあるのでクリック。<br>
client_secretウンタラカンタラ.jsonみたいなファイルがダウンロードされるので、
これを`client_secret.json`にリネームして、MasterDownloader.csと同じ場所に置く。

## UnityにGoogleAPI呼び出しdllをインポート
これがまた面倒。
要は`.dll`が欲しいだけで、パッケージのインストールとかはしない。<br>
以下はMacのVisualStudio for Mac Communityでの手順です。<br>
1. ひとまず、UnityエディタからVisualStudioを立ち上げる。
2. VSのメニューから`プロジェクト`>`Manage NuGet Packages`をクリック。
出てくるダイアログから<br>`Google.Apis` `Google.Apis.Auth` `Google.Apis.Drive.V3`を追加。
どのプロジェクトに追加するか聞かれたら<br>「Assembly.CSharp.Editor」が良さげ。
![スクリーンショット 2022-12-16 14.39.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3072360/ccd4dcfa-91a1-795b-22ed-144958b900d1.png)
3. 追加したら、Unityのプロジェクト直下の`Packages`フォルダに
それぞれのフォルダができてるので、
その中にある`.dll`を全て`MasterDownloader.cs`と同じフォルダか
その下にフォルダ掘るかしてコピー。
`.dll`全部コピーし終わったら
もう`Packages`フォルダの中に追加されたものは不要なので削除。

4. .dllの`.meta`ファイルを設定。
Unityを立ち上げて下のように設定して`Apply`。
![スクリーンショット 2022-12-16 22.17.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3072360/e48e2b30-449f-7820-d99f-9637c5b4a61b.png)

以上。これでスプレッドシートをエクセルとしてダウンロードできるはず。

なお、ユーザーが初めてアクセスする際、
ブラウザが立ち上がって認証許可を求められるので
`許可`を選んでください。結果は`credentials`に保存されるので、
二回目からは確認なしでダウンロード＆アップロードできます。

また、このcredentialsは開発チームで共有する必要ないので、
Unityプロジェクト直下にできる`SpreadSheets`フォルダと共に
`.gitignore`に突っ込んでおきます。