会議室の空き状況を確認するChromeの拡張機能を作った

# はじめに

こんにちは、genimuraです。
今回はchromeの拡張機能を作ってみました。
作ったものは、Googleカレンダーから会議室の空き状況を確認するものです。
Googleカレンダーを毎回開くのが億劫なので、拡張機能を作ってみました。
しかし、拡張機能の公開には1度だけ5$の費用がかかるので、まだ公開対応はしていません。(後々社内展開したいので範囲は未定ですが公開はやってみようと思います。)

ステップバイステップで作る過程をまとめてみます。
基本的にはこちらを参考にしました。
[OAuth 2.0: Google Cloud でユーザーを認証](https://developer.chrome.com/docs/extensions/how-to/integrate/oauth?hl=ja)

## 開発環境の作成

まずはどこでもいいので作業フォルダを作成しましょう。
今回は安易にshow_roomというフォルダを作成しました。

<details>
<summary>各種必要なファイルと役割の説明</summary>

#### マニフェストファイル (manifest.json)

chrome拡張機能を作成する際の構成ファイルです。このファイルには、拡張機能の名前、バージョン、権限、使用するファイルなどの情報が含まれています。

#### バックグラウンドスクリプト (background.js)

chrome拡張機能のバックグラウンドスクリプトです。このファイルには、拡張機能のバックグラウンド処理が記述されています。
ここで、後のpopup.htmlを表示するように設定しています。

#### コンテンツスクリプト (xxx.js)

chrome拡張機能のコンテンツスクリプトです。このファイルには、拡張機能のコンテンツ処理が記述されています。

#### ポップアップ (xxx.html)

chrome拡張機能のポップアップです。このファイルには、拡張機能のポップアップ画面が記述されています。

### オプションページ (xxx.html)

chrome拡張機能のオプションページです。このファイルには、拡張機能のオプションページが記述されています。


</details>

基本的にはただのhtml, css, jsの構成なので、適宜作成していきます。

## 今回やりたいことの処理の流れ

1. OAuth2.0でGoogleカレンダーのAPIを使用するための認証を行う
2. オプション画面でリソースIDの設定を行う
3. 認証後、GoogleカレンダーのAPIを使用して会議室の空き状況を取得する
4. 取得したデータをポップアップ画面に表示する
5. ポップアップ画面には、会議室の空き状況を表示する


## chrome拡張機能の作成

ではここから、作っていきましょう。
その前に準備として、各種ファイルを作成後、chrome拡張機能の管理ページにアクセスします。
デベロッパーモードを有効にするとパッケージ化されていない拡張機能を読み込むというボタンが表示されるので、作成したフォルダを選択します。
すると、chrome拡張機能の管理ページに作成したアプリが表示されます。

ここでIDをコピーしておきます。

この時点でのmanifest.jsonは以下のようになっています。

```json
{
  "manifest_version": 3,
  "name": "Show Room",
  "version": "1.0",
  "description": "Googleカレンダーのリソース(会議室)を表示する",
  "permissions": [],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html"
  }
}
```

## 1. OAuth2.0でGoogleカレンダーのAPIを使用するための認証を行う

次に、GoogleカレンダーのAPIを使用するための認証を行います。
Google Cloud PlatformでOAuth2.0の認証を行います。
Google Cloud Platformにアクセスし、API & Servicesのページに移動します。(プロジェクトを作成していない場合は、作成します。)
認証情報を作成ボタンからOAuth2.0クライアントIDを作成します。

![認証情報作成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730384/318ec672-d5b0-d092-6ca9-37668205e095.png)


必要事項を入力し、保存をするとクライアントIDが作成されます。

![クライアントID](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730384/eae954a2-3ccb-0dc6-6aae-9b154b39f471.png)
こちらもコピーし、manifest.jsonに記述します(後述します。)


## 2.manifest.jsonの設定

最後に最終的なmanifest.jsonを載せておきます。

### アイコンの作成

16x16, 48x48, 128x128のアイコンを作成します。
こちらは適当にChatGPT等で生成します。

### 拡張機能のパッケージ化
そして、この段階で一度、拡張機能をパッケージ化します。
APIを実行するためには、鍵情報が必要となるためです。
拡張機能の管理画面から拡張機能をパッケージ化ボタンを押します。

すると、パッケージ化され、鍵情報を生成します。
pemファイルの中身をコピーし、manifest.jsonに記述します(後述します)
ただし、pemファイルの中身は、`-----BEGIN PRIVATE KEY-----`と`-----END PRIVATE KEY-----`で囲まれているので、その部分を削除し、改行も削除します。

### 権限の設定

今回拡張機能に必要な権限は、`identify`と`storage`です。
`identify`は、ユーザーを認証するために必要な権限です。
`storage`は、ユーザーの設定を保存するために必要な権限です。

### OAuth2.0の設定

次は、OAuth2.0の設定を行います。
先ほど、Google Cloud Platformで作成したOAuth2.0クライアントIDをコピーし、manifest.jsonに記述します。
また、カレンダーの読み取り権限が必要なので、`https://www.googleapis.com/auth/calendar.readonly`を追加します。

### オプションページの設定

オプションページは、リソースIDの設定を行うためのページです。
オプションページの設定を行います。


以上がmanifest.jsonの設定です。
完成したmanifest.jsonは以下のようになります。

```json
{
  "manifest_version": 3,
  "name": "Show Room",
  "version": "1.0",
  "description": "Googleカレンダーのリソース(会議室)を表示する",
  "permissions": ["identity", "storage"],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "images/icon16.png",
      "48": "images/icon48.png",
      "128": "images/icon128.png"
    }
  },
  "icons": {
    "16": "images/icon16.png",
    "48": "images/icon48.png",
    "128": "images/icon128.png"
  },
  "options_page": "options.html",
  "oauth2": {
    "client_id": "クライアントID",
    "scopes": ["https://www.googleapis.com/auth/calendar.readonly"]
  },
  "key": "鍵情報"
}
```

## 3. オプションページの作成

オプションページでは、リソースIDと表示名の設定を行いました。

![オプションページ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730384/3c101e28-750e-74b3-91c4-fea7b270d306.png)

ソースは以下の通りです。

```html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>リソースID設定</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      padding: 20px;
      background-color: #f9f9f9;
    }

    h1 {
      font-size: 18px;
      margin-bottom: 20px;
    }

    #resource-container {
      margin-bottom: 20px;
    }

    .resource-item {
      display: flex;
      align-items: center;
      margin-bottom: 10px;
    }

    .resource-item input {
      flex: 1;
      padding: 5px;
      margin-right: 10px;
    }

    .resource-item button {
      padding: 5px 10px;
    }

    #add-resource,
    #save {
      padding: 10px 15px;
      background-color: #007bff;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }

    #add-resource:hover,
    #save:hover {
      background-color: #0056b3;
    }
  </style>
</head>

<body>
  <h1>Googleカレンダー リソースID設定</h1>
  <div id="resource-container"></div>
  <button id="add-resource">+</button>
  <button id="save">保存</button>
  <script src="options.js"></script>
</body>

</html>
```

```js
document.addEventListener('DOMContentLoaded', () => {
  const container = document.getElementById('resource-container');

  const loadResourceIds = () => {
    chrome.storage.local.get('resourceIds', ({ resourceIds = [] }) => {
      resourceIds.forEach(addResourceItem);
    });
  };

  const handleAddResource = () => addResourceItem({ id: '', displayName: '' });

  const handleSaveResources = () => {
    const resources = document.querySelectorAll('.resource-item');
    const resourceData = Array.from(resources).map(getResourceData);

    if (hasEmptyFields(resourceData)) {
      alert('リソースIDと表示名の両方を入力してください。');
      return;
    }

    if (hasDuplicateIds(resourceData)) {
      alert('重複するリソースIDがあります。');
      return;
    }

    saveResourceData(resourceData);
  };

  const addResourceItem = ({ id, displayName }) => {
    const resourceItem = document.createElement('div');
    resourceItem.className = 'resource-item';
    resourceItem.innerHTML = `
      <input type="text" class="resource-id" placeholder="リソースIDを入力" value="${id}">
      <input type="text" class="display-name" placeholder="表示名を入力" value="${displayName}">
      <button class="remove-resource">削除</button>
    `;
    resourceItem.querySelector('.remove-resource').addEventListener('click', () => {
      container.removeChild(resourceItem);
    });
    container.appendChild(resourceItem);
  };

  const getResourceData = (item) => {
    const id = item.querySelector('.resource-id').value.trim();
    const displayName = item.querySelector('.display-name').value.trim();
    return { id, displayName };
  };

  const hasEmptyFields = (resourceData) => {
    return resourceData.some(({ id, displayName }) => !id || !displayName);
  };

  const hasDuplicateIds = (resourceData) => {
    const ids = resourceData.map(({ id }) => id);
    return new Set(ids).size !== ids.length;
  };

  const saveResourceData = (resourceData) => {
    chrome.storage.local.set({ resourceIds: resourceData }, () => {
      alert('リソースIDが保存されました');
      window.close();
    });
  };

  loadResourceIds();
  document.getElementById('add-resource').addEventListener('click', handleAddResource);
  document.getElementById('save').addEventListener('click', handleSaveResources);
});

```

### 4. ポップアップページの作成

ポップアップページは、拡張機能のアイコンをクリックした際に表示されるページです。

![ポップアップページ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730384/54d47c30-b6fc-1b1e-0b97-2999cae96b93.png)

会議室一覧、登録したリソースが表示されます。
ボタンは、更新、カレンダー連携(OAuth2.0)、オプション設定を用意しました。

ソースは以下の通りです。

```html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="styles.css">
  <title>Show Room</title>
</head>

<body>
  <h1>会議室一覧</h1>
  <div id="room-list"></div>
  <button id="fetch-rooms">更新</button>
  <button id="oauth-button">カレンダー連携</button>
  <button id="options-button">オプション設定</button>
  <script src="popup.js"></script>
  <script src="oauth.js"></script>
</body>

</html>
```

```js
class GoogleCalendar {
  constructor(accessToken) {
    this.accessToken = accessToken;
  }

  async getEvents(calendarId) {
    try {
      const response = await fetch(this.buildUrl(calendarId), this.getRequestOptions());
      const data = await response.json();
      return data.items || [];
    } catch (error) {
      console.error('カレンダーのイベント取得に失敗しました。', error);
      return [];
    }
  }

  buildUrl(calendarId) {
    const now = new Date();
    const timeMax = new Date(now.getTime() + 60 * 60 * 1000).toISOString();
    return `https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events?timeMin=${now.toISOString()}&timeMax=${timeMax}&singleEvents=true&orderBy=startTime`;
  }

  getRequestOptions() {
    return {
      method: 'GET',
      headers: {
        Authorization: `Bearer ${this.accessToken}`,
        'Content-Type': 'application/json',
      },
      mode: 'cors',
    };
  }
}

function initializeRoomList() {
  const roomList = document.getElementById('room-list');
  if (!roomList) {
    console.error('Room list element not found.');
    return;
  }

  chrome.storage.local.get('resourceIds', ({ resourceIds = [] }) => {
    roomList.innerHTML = ''; // Clear existing content

    resourceIds.forEach(({ id, displayName }) => {
      const roomElement = createRoomElement(displayName);
      roomList.appendChild(roomElement);
    });

    // Update availability status
    updateRoomList();
  });
}

function createRoomElement(displayName) {
  const roomElement = document.createElement('div');
  roomElement.className = 'room-item';

  const roomName = document.createElement('div');
  roomName.textContent = displayName;
  roomElement.appendChild(roomName);

  const status = document.createElement('div');
  status.className = 'status'; // Reserve space for status
  roomElement.appendChild(status);

  return roomElement;
}

function updateRoomList() {
  const roomList = document.getElementById('room-list');
  if (!roomList) {
    console.error('会議室が見つかりません。');
    return;
  }

  chrome.storage.local.get(
    ['resourceIds', 'accessToken'],
    async ({ resourceIds = [], accessToken = '' }) => {
      if (!accessToken) {
        alert('カレンダー連携をしてください。');
        return;
      }

      const calendar = new GoogleCalendar(accessToken);

      for (const { id, displayName } of resourceIds) {
        const roomElement = findRoomElement(roomList, displayName);
        if (!roomElement) continue;

        const events = await calendar.getEvents(id);
        const isOccupied = filterOngoingEvents(events).length > 0;

        updateRoomStatus(roomElement, isOccupied);
      }
    }
  );
}

function findRoomElement(roomList, displayName) {
  const roomElements = roomList.getElementsByClassName('room-item');
  for (let element of roomElements) {
    const roomNameDiv = element.querySelector('div');
    if (roomNameDiv && roomNameDiv.textContent === displayName) {
      return element;
    }
  }
  return null;
}

function filterOngoingEvents(events) {
  const now = new Date();
  return events.filter(({ start, end }) => {
    const startTime = new Date(start.dateTime || start.date);
    const endTime = new Date(end.dateTime || end.date);
    return startTime <= now && now <= endTime;
  });
}

function updateRoomStatus(roomElement, isOccupied) {
  const status = roomElement.querySelector('.status');
  status.className = `status ${isOccupied ? 'occupied' : 'available'}`;
}

document.addEventListener('DOMContentLoaded', initializeRoomList);

document.getElementById('options-button').addEventListener('click', () => {
  chrome.runtime.openOptionsPage();
});

document.getElementById('fetch-rooms').addEventListener('click', updateRoomList);
```

### 5. バックグラウンドスクリプトの作成

バックグラウンドスクリプトは、ポップアップページを表示するためのスクリプトです。

```js
chrome.action.onClicked.addListener(function () {
  chrome.tabs.create({ url: 'popup.html' });
});

```

### 6. oauth.jsの作成

oauth.jsは、OAuth2.0の認証を行うためのスクリプトです。
こちらは、popup.htmlで呼び出しています。
OAuth2.0の認証を行い、アクセストークンを取得します。


```js
window.onload = function () {
  document.getElementById('oauth-button').addEventListener('click', function () {
    chrome.identity.getAuthToken({ interactive: true }, function (token) {
      if (token) {
        chrome.storage.local.set({ accessToken: token });
        alert('カレンダー連携に成功しました。');
        chrome.runtime.openOptionsPage();
      } else {
        alert('カレンダー連携に失敗しました。');
      }
    });
  });
};
```

### 完成したものがこちら

弊社では会議室が都市名です。
空いているときは◯、使用中は×で表示されます。

![動きあるもの](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730384/533d8d8b-621e-2f36-68d5-c745f9f79469.gif)


### ハマったポイント


- Chrome拡張機能のIDと、Google Cloud PlatformのアイテムIDに入れるのが正解なのかがわからなかった。(OAuth2.0の認証ができるまでは懐疑的)
- パッケージ化する際のKeyが必須なのがわからず、なかなかAPIを実行できなかった。 (KeyがないとOAuth2.0の認証ができない)

今回ハマりポイントとして、OAuth2.0の認証がなかなか通らず、原因を調査していましたが、そのときにredirect_uri_mismatchというエラーが出ていました。
このエラーはredirect_uriが一致していても出てしまい、原因の特定に時間がかかりました。
結局原因は、manifest.json内で、oauth2の設定と、keyを設定することで解決しました。

### おわりに

今回は初めてChrome拡張機能を作ってみました。
慣れてしまえば、簡単に作れるものだったので、今後もちょこっと役に立つものを作ってみようと思います。