DriveAPIManager v3

Google Drive appdata スコープ管理ライブラリ — 全メソッドリファレンス

モード比較

client(デフォルト)server
サーバー不要(GitHub Pages 可)必要
CLIENT_SECRETコードに含めるサーバー側で管理
PKCE❌(不要)
refresh_token 保存先AES-GCM 暗号化 → localStorageサーバー側セッション
access_token 保存先メモリ(#プライベートフィールド)共通

セットアップ — client モード

GitHub Pages など、サーバーなしで動作します。clientSecret は Google Cloud Console の「ウェブアプリケーション」タイプで発行したものを指定してください。

clientSecret はソースコードに含まれるため、公開リポジトリでは誰でも読めます。 drive.appdata スコープ限定なのでそのアプリのデータしか操作できませんが、 より高いセキュリティが必要な場合は server モードを使用してください。
import DriveAPIManager from './DriveAPIManager.js';

const drive = new DriveAPIManager({
  clientId:     'YOUR_CLIENT_ID.apps.googleusercontent.com',
  clientSecret: 'YOUR_CLIENT_SECRET',       // ウェブアプリタイプに必要
  redirectUri:  'https://yourname.github.io/yourrepo/oauth2callback',
  // mode: 'client' はデフォルトなので省略可
  progress: (phase, detail) => console.log(`[${phase}]`, detail),
});

セットアップ — server モード

CLIENT_SECRET をサーバー側で管理します。クライアントには access_token だけ渡し、refresh_token はサーバーセッションに保持します。

const drive = new DriveAPIManager({
  clientId:    'YOUR_CLIENT_ID.apps.googleusercontent.com',
  redirectUri: 'https://yourapp.com/oauth2callback',
  mode:        'server',
  tokenUrl:    '/auth/token',    // POST code → {access_token, expires_in}
  refreshUrl:  '/auth/refresh',  // POST     → {access_token, expires_in}
  revokeUrl:   '/auth/revoke',   // POST     → 200
});

サーバー側 Express 実装例:

// POST /auth/token  — code を受け取り、refresh_token をセッションに保持
app.post('/auth/token', async (req, res) => {
  const { code, redirect_uri } = req.body;
  const r = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: CLIENT_ID, client_secret: CLIENT_SECRET,
      grant_type: 'authorization_code', code, redirect_uri,
    }),
  });
  const data = await r.json();
  req.session.refresh_token = data.refresh_token; // サーバー側で保持
  res.json({ access_token: data.access_token, expires_in: data.expires_in });
});

// POST /auth/refresh
app.post('/auth/refresh', async (req, res) => {
  const rt = req.session.refresh_token;
  if (!rt) return res.status(401).json({ error: 'not_authenticated' });
  const r = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: CLIENT_ID, client_secret: CLIENT_SECRET,
      grant_type: 'refresh_token', refresh_token: rt,
    }),
  });
  const data = await r.json();
  res.json({ access_token: data.access_token, expires_in: data.expires_in });
});

// POST /auth/revoke
app.post('/auth/revoke', async (req, res) => {
  const rt = req.session.refresh_token;
  if (rt) await fetch(`https://oauth2.googleapis.com/revoke?token=${rt}`, { method: 'POST' });
  req.session.destroy();
  res.json({ ok: true });
});

oauth2callback.html

Google がリダイレクトしてくる着地ページ。これをリポジトリに置いて、Google Cloud Console の「承認済みリダイレクト URI」に登録してください。

<!-- yourrepo/oauth2callback.html -->
<!DOCTYPE html>
<html><head><meta charset="UTF-8"></head>
<body><script>
  const p = new URLSearchParams(location.search);
  window.opener?.postMessage({
    type:  'oauth_callback',
    code:  p.get('code'),
    state: p.get('state'),
    error: p.get('error'),
  }, location.origin);
  window.close();
</script></body>
</html>

AUTH

auth(silent?) 両モード

async auth(silent?: boolean = true): Promise<string>

メインの認証エントリ。内部で以下の順に試みます:

  1. メモリに有効な access_token がある → そのまま返す
  2. client: 暗号化 RT を復号してリフレッシュ / server: /auth/refresh を叩く
  3. silent=false のときのみ → ポップアップを開く
エラーコード意味
NOT_AUTHENTICATEDトークンなし(silent=true 時)
REFRESH_FAILEDリフレッシュ失敗
POPUP_BLOCKEDポップアップがブロックされた
POPUP_CLOSEDユーザーが閉じた
AUTH_DENIEDアクセス拒否
AUTH_TIMEOUT5 分タイムアウト
STATE_MISMATCHCSRF 検知
DECRYPT_FAILED保存済み RT の復号失敗(client のみ)
SERVER_TOKEN_FAILEDサーバー側コード交換失敗(server のみ)
SERVER_REFRESH_FAILEDサーバー側リフレッシュ失敗(server のみ)
// 初回ログイン(ポップアップを許可)
await drive.auth(false);

// ページ読み込み時のサイレント復元
try {
  await drive.auth(true);
  showApp();
} catch (e) {
  if (e.code === 'NOT_AUTHENTICATED') showLoginButton();
}

// エラーハンドリング例
try {
  await drive.auth(false);
} catch (e) {
  switch (e.code) {
    case 'POPUP_BLOCKED': alert('ポップアップを許可してください'); break;
    case 'AUTH_DENIED':   alert('アクセスを許可してください');   break;
    default:             console.error(e);
  }
}

checker() 両モード

checker(): { loggedIn, expired, hasRefreshToken, mode }

同期。API 呼び出しなし。ページ読み込み時の UI 出し分けに使います。

const status = drive.checker();
// {
//   loggedIn:        true,    // トークンあり(有効期限問わず)
//   expired:         false,   // access_token が期限切れか
//   hasRefreshToken: true,    // RT あり
//   mode:            'client' // 'client' | 'server'
// }

// 典型的な使い方
if (drive.checker().hasRefreshToken) {
  await drive.auth(true);  // サイレントで復元
  showApp();
} else {
  showLoginButton();
}

getEmail() 両モード

async getEmail(): Promise<string>
const email = await drive.getEmail();
console.log(email); // 'user@gmail.com'

document.getElementById('user-label').textContent = await drive.getEmail();

signOut() 両モード

async signOut(): Promise<void>

client: Google サーバーに revoke → localStorage 消去
server: /auth/revoke に POST → メモリ消去

document.getElementById('logout').addEventListener('click', async () => {
  await drive.signOut();
  location.href = '/';
});

パス解決

パスは / 区切り。先頭の / は任意('/saves/slot1.json''saves/slot1.json' は同じ)。

getFileId(path)

async getFileId(path: string): Promise<string | null>

パス → Drive ファイル ID。なければ null。内部キャッシュ付きで同じパスの 2 回目以降は API なし。

const id = await drive.getFileId('/saves/slot1.json');
// 'aBcDeFgHiJkLmNoPqRsTuV' または null

// 存在チェック
const exists = (await drive.getFileId('/config/settings.json')) !== null;

getPath(fileId)

async getPath(fileId: string): Promise<string>
const path = await drive.getPath('aBcDeFgHiJkLmNoPqRsTuV');
// '/saves/slot1.json'

// search() の結果からパスを逆引き
const results = await drive.search('slot');
for (const f of results) {
  console.log(await drive.getPath(f.id), f.size);
}

フォルダ

createFolder(path)

async createFolder(path: string): Promise<string>

フォルダを冪等に作成(中間フォルダも自動作成、既存はスキップ)。返り値は末端フォルダの ID。

const folderId = await drive.createFolder('/saves/2024/april');
// '/saves', '/saves/2024', '/saves/2024/april' を順に作成(存在すればスキップ)

ファイル CRUD

getFile(pathOrId, as?)

async getFile(pathOrId: string, as?: 'auto'|'json'|'text'|'arraybuffer'|'blob'): Promise<any>
as戻り値自動選択される拡張子
'auto'(デフォルト)拡張子で自動判定
'json'object.json
'text'string.txt .md .csv .tsv .html .xml
'arraybuffer'ArrayBuffer.png .jpg .jpeg .gif .webp .pdf .zip .bin
'blob'Blob
// JSON(拡張子から自動で object)
const config = await drive.getFile('/config/settings.json');

// テキスト
const readme = await drive.getFile('/docs/readme.md', 'text');

// 画像 → <img> に表示
const buf = await drive.getFile('/assets/icon.png', 'arraybuffer');
const url = URL.createObjectURL(new Blob([buf], { type: 'image/png' }));
document.getElementById('icon').src = url;

// ID で取得
const data = await drive.getFile('aBcDeFgHiJkLmNoPqRsTuV', 'json');

// ファイルが存在しない場合のハンドリング
try {
  const save = await drive.getFile('/saves/slot3.json');
} catch (e) {
  if (e.code === 'FILE_NOT_FOUND') console.log('セーブデータなし');
}

saveFile(path, data, opts?)

async saveFile(path: string, data: object|string|ArrayBuffer|Uint8Array|Blob, opts?: object): Promise<{id, name, modifiedTime}>

作成 or 上書き保存。親フォルダは自動作成。

// JSON
const file = await drive.saveFile('/config/settings.json', {
  theme: 'dark', volume: 0.8, lang: 'ja',
});
console.log(file.id, file.modifiedTime);

// テキスト
await drive.saveFile('/logs/today.txt', '起動: 09:00\n終了: 17:30');

// ArrayBuffer(画像)
const buf = await (await fetch('/local/avatar.png')).arrayBuffer();
await drive.saveFile('/profile/avatar.png', buf, { mimeType: 'image/png' });

// canvas → PNG
const blob = await new Promise(r => canvas.toBlob(r, 'image/png'));
await drive.saveFile('/screenshots/shot1.png', blob, { mimeType: 'image/png' });

// appProperties(Drive の独自 KV)を付与
await drive.saveFile('/saves/slot1.json', saveData, {
  meta: { appProperties: { version: '2', stage: '5' } },
});

copyFile(srcPathOrId, destPath)

async copyFile(srcPathOrId: string, destPath: string): Promise<{id, name, modifiedTime}>
// バックアップ
await drive.copyFile('/saves/slot1.json', '/backups/slot1_20240401.json');

// ID から
await drive.copyFile('aBcDeFgHiJkLmNoPqRsTuV', '/saves/slot2.json');

moveFile(pathOrId, newPath)

async moveFile(pathOrId: string, newPath: string): Promise<object>
// リネーム
await drive.moveFile('/saves/draft.json', '/saves/final.json');

// 別フォルダへ移動(フォルダは自動作成)
await drive.moveFile('/tmp/data.json', '/saves/2024/data.json');

// 移動 + リネーム同時
await drive.moveFile('/inbox/report.json', '/archive/2024/report_done.json');

一覧 / 構造

listFiles(pathOrId?)

async listFiles(pathOrId?: string): Promise<FileEntry[]>

フォルダ直下の一覧。引数なし・'''/' で appDataFolder 直下。

// ルート直下
const root = await drive.listFiles();

// フォルダか判定しながら表示
for (const f of await drive.listFiles('/saves')) {
  const isFolder = f.mimeType === 'application/vnd.google-apps.folder';
  const size     = f.size ? `${(f.size/1024).toFixed(1)} KB` : '-';
  const date     = new Date(f.modifiedTime).toLocaleString('ja-JP');
  console.log(`${isFolder ? '📁' : '📄'} ${f.name}  ${size}  ${date}`);
}

getStructure()

async getStructure(): Promise<DriveNode>

appDataFolder 全体のツリーを 1 回の API で取得。DriveNode = { id, name, type:'folder'|'file', size?, modifiedTime?, children? }

const tree = await drive.getStructure();

// 全ファイルをフラットに列挙
function flatten(node, path = '') {
  const cur = path + '/' + node.name;
  if (node.type === 'file') return [{ path: cur, size: node.size, id: node.id }];
  return (node.children ?? []).flatMap(c => flatten(c, cur));
}
const all = flatten(tree);

// ツリーを <ul> でレンダリング
function renderTree(node, ul = document.createElement('ul')) {
  const li = document.createElement('li');
  li.textContent = (node.type === 'folder' ? '📁 ' : '📄 ') + node.name;
  ul.appendChild(li);
  if (node.children) {
    const sub = document.createElement('ul');
    node.children.forEach(c => renderTree(c, sub));
    li.appendChild(sub);
  }
  return ul;
}
document.body.appendChild(renderTree(tree));

検索 / メタデータ

async search(query: string, opts?: { mimeType?, limit? }): Promise<FileEntry[]>
// 名前に 'slot' を含む
const results = await drive.search('slot');

// JSON ファイルのみ
const jsonFiles = await drive.search('', { mimeType: 'application/json' });

// 件数制限(デフォルト 50)
const top5 = await drive.search('save', { limit: 5 });

getMeta(pathOrId, fields?)

async getMeta(pathOrId: string, fields?: string): Promise<object>
// デフォルト(id, name, mimeType, size, modifiedTime, parents)
const meta = await drive.getMeta('/saves/slot1.json');

// appProperties も取得
const m = await drive.getMeta('/saves/slot1.json', 'id,name,appProperties');
console.log(m.appProperties); // { version: '2', stage: '5' }

updateMeta(pathOrId, meta)

async updateMeta(pathOrId: string, meta: object): Promise<object>
// リネーム
await drive.updateMeta('/saves/slot1.json', { name: 'slot1_cleared.json' });

// appProperties 更新
await drive.updateMeta('/saves/slot1.json', {
  appProperties: { version: '3', savedAt: new Date().toISOString() },
});

削除

remove(pathOrId)

async remove(pathOrId: string): Promise<void>
await drive.remove('/tmp/draft.json');
await drive.remove('/tmp');                       // フォルダごと
await drive.remove('aBcDeFgHiJkLmNoPqRsTuV');  // ID でも可

removeAll()

async removeAll(): Promise<void>
⚠ appDataFolder 内を全消去します。不可逆です。
if (confirm('全データを削除しますか?')) {
  await drive.removeAll();
}

実装パターン

ゲームのセーブ・ロード

const drive = new DriveAPIManager({ clientId: '...', clientSecret: '...' });

// 起動時
async function init() {
  if (drive.checker().hasRefreshToken) {
    await drive.auth(true);
    loadGame();
  } else {
    document.getElementById('login').style.display = 'block';
  }
}

document.getElementById('login').addEventListener('click', async () => {
  await drive.auth(false);
  loadGame();
});

// セーブ
async function save(slot) {
  const data = { stage: currentStage, score, timestamp: Date.now() };
  await drive.saveFile(`/saves/slot${slot}.json`, data, {
    meta: { appProperties: { stage: String(currentStage) } },
  });
}

// ロード
async function load(slot) {
  try {
    return await drive.getFile(`/saves/slot${slot}.json`);
  } catch (e) {
    if (e.code === 'FILE_NOT_FOUND') return null;
    throw e;
  }
}

// セーブ一覧
async function getSaveList() {
  const files = await drive.listFiles('/saves');
  return files.map(f => ({
    slot: f.name.replace('slot', '').replace('.json', ''),
    date: new Date(f.modifiedTime).toLocaleString('ja-JP'),
    size: f.size,
  }));
}

設定の同期

const DEFAULTS = { theme: 'light', lang: 'ja', volume: 1.0 };

async function loadPrefs() {
  try {
    return { ...DEFAULTS, ...await drive.getFile('/config/prefs.json') };
  } catch (e) {
    if (e.code === 'FILE_NOT_FOUND') return DEFAULTS;
    throw e;
  }
}

async function savePrefs(prefs) {
  await drive.saveFile('/config/prefs.json', prefs);
}

画像アップロード+ギャラリー表示

// input[type=file] からアップロード
document.getElementById('upload').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return;
  await drive.saveFile(`/images/${file.name}`, file, { mimeType: file.type });
});

// ギャラリー表示
async function showGallery() {
  const images = await drive.search('', { mimeType: 'image/png', limit: 50 });
  for (const f of images) {
    const buf = await drive.getFile(f.id, 'arraybuffer');
    const img = document.createElement('img');
    img.src   = URL.createObjectURL(new Blob([buf], { type: 'image/png' }));
    document.getElementById('gallery').appendChild(img);
  }
}

全データのエクスポート(ダウンロード)

async function exportAll() {
  const tree     = await drive.getStructure();
  const allFiles = [];

  async function walk(node, path) {
    if (node.type === 'file') {
      const content = await drive.getFile(node.id, 'text');
      allFiles.push({ path: path + '/' + node.name, content });
    } else {
      for (const child of node.children ?? [])
        await walk(child, path + '/' + node.name);
    }
  }
  await walk(tree, '');

  const blob = new Blob([JSON.stringify(allFiles, null, 2)], { type: 'application/json' });
  const a   = document.createElement('a');
  a.href     = URL.createObjectURL(blob);
  a.download = 'export.json';
  a.click();
}

progress コールバックで進捗表示

const drive = new DriveAPIManager({
  clientId:     '...',
  clientSecret: '...',
  progress: (phase, detail) => {
    const map = {
      'auth:start':      '認証中…',
      'auth:memory_hit': '✅ 認証済み',
      'auth:refreshing': '🔄 トークン更新中…',
      'auth:done':       '✅ ログイン完了',
      'auth:signed_out': 'サインアウトしました',
    };
    const key = `${phase}:${detail}`;
    const prefix = {
      saveFile:     '💾 保存中: ',
      createFolder: '📁 フォルダ作成: ',
      remove:       '🗑 削除: ',
      removeAll:    '🗑 全消去: ',
    };
    const msg = map[key] ?? (prefix[phase] ? prefix[phase] + detail : null);
    if (msg) document.getElementById('status').textContent = msg;
  },
});