###### tags: `Angular` `Google` `Calendar`
# 🌝[T]Google Calendar
## 需求簡述
SD希望我們的專案可以幫使用者將業務行事曆的事件匯入使用者的google calendar中。
## 資料蒐集
[Google Calendar API Guides](https://developers.google.com/calendar/overview)
- 主要參考`JavaScript`
- Gsuit: 內部的人使用的,意思是為此企業的帳戶(gmail被標上Gsuit)才可以被使用
[Google Calendar API Event](https://developers.google.com/calendar/v3/reference/events#resource)
- API參數
## 前置作業
開始使用Google API前,必須要先按照Google的指示做設定,設定完成後才可以開始使用Google Calendae API,寫的程式才能正常執行。
:::success
📝**小補充**
以下的google設定步驟可能會隨著每年不斷地改變,所以有不一樣的地方,可以查看官方的設定文件,內容也會比較詳細準確。
:::
### *step1.* 建立專案
首先先到[GoogleAPIs]( https://console.developers.google.com)建立一個專案。

### *step2.* 啟用API
若尚未啟用此API要先至[API Library](https://console.cloud.google.com/apis/library),搜尋`Google Calendar API`並啟用。

### *step3.* 建立API KEY
回到專案 > [憑證頁籤](https://console.cloud.google.com/apis/credentials),建立一組新的`API KEY` (API 金鑰)。
- 自行決定是否要設定限制API。(有設定前面會有綠勾勾,沒設定會是黃嘆號)

### *step4.* 建立Client ID (OAuth2.0 Client ID)
一樣在專案 > [憑證頁籤](https://console.cloud.google.com/apis/credentials),建立一個新的`OAuth Client ID`。
- 內容需填寫的資料是關於應用程式的相關資料,依照自己的專案填寫就好所以這邊就不放上了。

### *step5.* 驗證OAuth同意畫面
**為什麼要驗證?**
有使用OAuth Client ID的API,在要求權限時會跳出畫面詢問使用者是否同意應用程式存取,若是沒有驗證OAuth同意畫面,會多跳出一個畫面是`此應用程式不安全`,並且在使用到達配額限制後,會不能讓使用者使用。
**注意事項**
裡面要填寫的內容,依照自己的專案填寫就可以,但有一些需要注意的地方。
1. OAuth同意畫面 > 已授權的網域
- 在OAuth Client API中有填寫到的網域,必須都要在此填上。
- 並需要前往[Google Search Console](https://search.google.com/search-console)進行驗證。
- 授權的網域必須為`有效`的網域。 (無效例子: `localhost:4200`)
2. 範圍
- API的範圍分三種,依照不同的範圍使用,有不一樣的審核標準。
1. 非機密的範圍
2. 機密範圍/敏感範圍
3. 受限制的範圍
- 機密與受限制的範圍都必須錄製英文影片給Google審核,並補上說明,下方為Google範例。
```
我的應用程序將使用https://www.googleapis.com/auth/calendar
在我的應用程序的計劃屏幕上顯示用戶的Google日曆數據,
以便用戶可以通過我的應用程序管理他們的計劃並將更改與他們的同步Google日曆。
```
其他相關的驗證過程在下方筆記內(OAuth Verification)。
## 實現筆記
### Create Event
**文件範例**
==index.html==
```htmlembedded=
<!DOCTYPE html>
<html>
<head>
<title>Google Calendar API Quickstart</title>
<meta charset="utf-8" />
</head>
<body>
<p>Google Calendar API Quickstart</p>
<!--Add buttons to initiate auth sequence and sign out-->
<button id="authorize_button" style="display: none;">Authorize</button>
<button id="signout_button" style="display: none;">Sign Out</button>
<pre id="content" style="white-space: pre-wrap;"></pre>
<script type="text/javascript">
// Client ID and API key from the Developer Console
var CLIENT_ID = '<YOUR_CLIENT_ID>';
var API_KEY = '<YOUR_API_KEY>';
// Array of API discovery doc URLs for APIs used by the quickstart
var DISCOVERY_DOCS = ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"];
// Authorization scopes required by the API; multiple scopes can be
// included, separated by spaces.
var SCOPES = "https://www.googleapis.com/auth/calendar.readonly";
var authorizeButton = document.getElementById('authorize_button');
var signoutButton = document.getElementById('signout_button');
/**
* On load, called to load the auth2 library and API client library.
*/
function handleClientLoad() {
gapi.load('client:auth2', initClient);
}
/**
* Initializes the API client library and sets up sign-in state
* listeners.
*/
function initClient() {
gapi.client.init({
apiKey: API_KEY,
clientId: CLIENT_ID,
discoveryDocs: DISCOVERY_DOCS,
scope: SCOPES
}).then(function () {
// Listen for sign-in state changes.
gapi.auth2.getAuthInstance().isSignedIn.listen(updateSigninStatus);
// Handle the initial sign-in state.
updateSigninStatus(gapi.auth2.getAuthInstance().isSignedIn.get());
authorizeButton.onclick = handleAuthClick;
signoutButton.onclick = handleSignoutClick;
}, function(error) {
appendPre(JSON.stringify(error, null, 2));
});
}
/**
* Called when the signed in status changes, to update the UI
* appropriately. After a sign-in, the API is called.
*/
function updateSigninStatus(isSignedIn) {
if (isSignedIn) {
authorizeButton.style.display = 'none';
signoutButton.style.display = 'block';
listUpcomingEvents();
} else {
authorizeButton.style.display = 'block';
signoutButton.style.display = 'none';
}
}
/**
* Sign in the user upon button click.
*/
function handleAuthClick(event) {
gapi.auth2.getAuthInstance().signIn();
}
/**
* Sign out the user upon button click.
*/
function handleSignoutClick(event) {
gapi.auth2.getAuthInstance().signOut();
}
/**
* Append a pre element to the body containing the given message
* as its text node. Used to display the results of the API call.
*
* @param {string} message Text to be placed in pre element.
*/
function appendPre(message) {
var pre = document.getElementById('content');
var textContent = document.createTextNode(message + '\n');
pre.appendChild(textContent);
}
/**
* Print the summary and start datetime/date of the next ten events in
* the authorized user's calendar. If no events are found an
* appropriate message is printed.
*/
function listUpcomingEvents() {
gapi.client.calendar.events.list({
'calendarId': 'primary',
'timeMin': (new Date()).toISOString(),
'showDeleted': false,
'singleEvents': true,
'maxResults': 10,
'orderBy': 'startTime'
}).then(function(response) {
var events = response.result.items;
appendPre('Upcoming events:');
if (events.length > 0) {
for (i = 0; i < events.length; i++) {
var event = events[i];
var when = event.start.dateTime;
if (!when) {
when = event.start.date;
}
appendPre(event.summary + ' (' + when + ')')
}
} else {
appendPre('No upcoming events found.');
}
});
}
</script>
<script async defer src="https://apis.google.com/js/api.js"
onload="this.onload=function(){};handleClientLoad()"
onreadystatechange="if (this.readyState === 'complete') this.onload()">
</script>
</body>
</html>
```
**實際撰寫**
上方的參考範例是Javascript,那我們專案使用的是Angular架構,所以有調整一下寫法(改成MVC),但api的使用方法是一樣的。
==oauth-calendar.service.ts==
```typescript=
export class OauthCalendarService {
constructor() { }
handleClientLoad(): void {
gapi.load('client:auth2', this.initClient);
}
initClient(): void {
const CLIENT_ID = '<YOUR_CLIENT_ID>';
const API_KEY = '<YOUR_API_KEY>';
// Array of API discovery doc URLs for APIs used by the quickstart
const DISCOVERY_DOCS = ['https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest'];
// Authorization scopes required by the API; multiple scopes can be
// included, separated by spaces.
const SCOPES = 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.app.created';
gapi.client.init({
apiKey: API_KEY,
clientId: CLIENT_ID,
discoveryDocs: DISCOVERY_DOCS,
scope: SCOPES
}).then(() => {
}, (error) => {
console.log(' Error', error);
});
}
signIn() {
return gapi.auth2.getAuthInstance().signIn();
}
signOut() {
return gapi.auth2.getAuthInstance().signOut();
}
createEvent(calendatId: string, event: gapi.client.calendar.Event) {
return gapi.client.calendar.events.insert({
calendarId: calendatId,
resource: event
});
}
}
```
==oauth-calendar.component.ts==
```typescript=
export class OauthCalendarComponent implements OnInit {
constructor(
private oauthCalendarService: OauthCalendarService,
private changeDetectorRef: ChangeDetectorRef,
) { }
ngOnInit() {
this.oauthCalendarService.handleClientLoad();
}
signIn(event): void {
console.log('ruby 000 signIn()', event);
this.oauthCalendarService.signIn().then(
(res: any) => {
console.log('ruby 001 signIn()', res);
const scope: string[] = res.wc.scope.split(' ');
console.log('ruby 003', scope);
const isAuth = scope.find(x => x === 'https://www.googleapis.com/auth/calendar');
if (!!isAuth) {
this.isLogin = true;
this.changeDetectorRef.detectChanges();
}
},
(err) => {
console.log('ruby 002 signIn()', err);
}
);
}
createEvent(): void {
let isSuc = true;
this.events.forEach(element => {
const calendarId = 'primary';
const event: gapi.client.calendar.Event = {
summary: element.title,
location: 'Ruby Location',
description: 'Ruby Calendar Test',
start: {
dateTime: <any>element.start,
timeZone: 'Asia/Taipei'
},
end: {
dateTime: <any>element.start,
timeZone: 'Asia/Taipei'
},
};
this.oauthCalendarService.createEvent(calendarId, event).execute(
(res) => {
console.log('ruby 000 createEvent()', res);
if (res.status !== "confirmed") {
isSuc = false;
}
}
);
});
alert(isSuc ? '上傳成功' : '上傳有誤');
}
syncEvents() {
this.events.forEach(element => {
const event: gapi.client.calendar.Event = {
summary: element.title,
location: 'Ruby Location',
description: 'Ruby Calendar Test',
start: {
dateTime: <any>element.start,
timeZone: 'Asia/Taipei'
},
end: {
dateTime: <any>element.start,
timeZone: 'Asia/Taipei'
},
};
const request = gapi.client.calendar.events.insert({
calendarId: 'primary',
resource: event
});
request.execute(res => {
console.log("ruby ", res);
if (res.status === "confirmed") {
console.log('ruby 新增成功');
}
});
});
}
}
```
==oauth-calendar.html==
```htmlembedded=
<div class="mainContent-footer-btns">
<input type="checkbox" [(ngModel)]="isAgree">同意上述事項
</div>
<div class="mainContent-footer-btns">
<button id="authorize_button" class="btn btn-white" style="margin: 0 100px;" (click)="signIn($event)" [disabled]="!isAgree">
<img src="./images/google/btn_google_signin_dark_normal_web.png">
</button>
<button id="authorize_button" class="btn btn-green" style="margin: 0 100px; padding: 10px 12px" (click)="createEvent()" [disabled]="!isLogin">
<b>上傳到您的google行事曆</b>
</button>
</div>
```
### OAuth Verification
#### #Privacy policy
必須提供`隱私權政策`的連結
- 後台需要給此連結,會被放在跳出權限要求時。
- 首頁也需要提供。
#### #Homepage
首頁的內容必須要有
- 告訴使用者如何操作。
- 告訴使用者將訪問的權限。
- 有效的隱私權政策連結。 [暫時先用這個](https://a1.digiwin.com/privacy.php)

:::info
💡小補充
首頁的內容如果沒有向使用者清楚描述==如何操作==及==使用範圍==,在審核時被退回,可以的話盡量描述的仔細一點。
:::
#### #Demain verification
網域需要到[Google Search Console](https://search.google.com/search-console)驗證所有權。

#### #Demo Video (English)
有使用到敏感/限制範圍(SCOPE)的API才需要。
- 必須要翻譯成英文(可使用Google翻譯)。==首頁及權限頁必須要==


- 內必須呈現client_ID。

- 登入按鈕必須符合Google登入的[品牌政策](https://developers.google.com/identity/branding-guidelines)。(這部分不放在首頁是因為,主頁與實際的操作頁是可以不同的,但都得在受認證的網域內。)

- 需上傳到Youtube且設為公開
## 錯誤集
[Google Calendar API - Handle API Errors](https://developers.google.com/calendar/v3/errors)
### ⚡Status Code 400
#### 錯誤訊息
`⚡`
#### 發生情境
程式中設定的`API KEY`與`ClientID`,在[GoogleAPIs]( https://console.developers.google.com)憑證內設定的網域是
```
https://a1web.azurewebsites.net/
```
但我用`npm run start_`執行程式時的網域是
```
http://localhost:4200/
```
#### 解決方法
新開一個專案,然後將網域設定`http://localhost:4200/`,再把程式的換上新的`API KEY`與`ClientID`
#### 錯誤原因
[GoogleAPIs]( https://console.developers.google.com)憑證的`API KEY`或是`ClientID` 內設置的網域,與開啟程式的網域不相符。
### ⚡Status Code 403
#### 錯誤訊息
`⚡`
#### 發生情境
程式中設定的`SCOPE`
```typescript
const SCOPE = 'https://www.googleapis.com/auth/calendar'
```
[GoogleAPIs]( https://console.developers.google.com)-OAuth同意畫面-範圍
```
https://www.googleapis.com/auth/calendar.app.created
```
#### 解決方法
將程式的的`SCOPE`改成
```typescript
const SCOPE = 'https://www.googleapis.com/auth/calendar.app.created'
```
#### 錯誤原因
程式中設定的`SCOPE`與[GoogleAPIs]( https://console.developers.google.com)-OAuth同意畫面-範圍,所設定的不一致。
### ⚡Status Code 404
#### 錯誤訊息
`⚡`
#### 發生情境
- *情境一.* 範圍問題
API的使用範圍為`建立次要 Google 日曆,以及查看、建立、變更及刪除這類日曆的活動`,新增事件至`calendarId: 'primary'`日曆內。
```typescript
const SCOPE = 'https://www.googleapis.com/auth/calendar.app.created'
```
```typescript
gapi.client.calendar.events.insert({
calendarId: 'primary',
resource: {
summary: '大綱',
location: '位址',
description: '描述',
start: {
dateTime: new Date,
timeZone: 'Asia/Taipei'
},
}
}).execute((res) => console.log(res))
```
- *情境二.* calendarId問題
想新增事件至次要的日曆中`calendarId: 'secondary'`
```typescript
gapi.client.calendar.events.insert({
calendarId: 'secondary',
resource: {
summary: '大綱',
location: '位址',
description: '描述',
start: {
dateTime: new Date,
timeZone: 'Asia/Taipei'
},
}
}).execute((res) => console.log(res))
```
#### 解決方法
- *情境一.* 範圍問題
將範圍改使用
```typescript
const SCOPE = 'https://www.googleapis.com/auth/calendar'
```
- *情境二.* calendarId問題
calendarId可以在`日曆>設定>整合日曆`查看,然後換上要被新增事件的日曆calendarId換上即可。

#### 錯誤原因
- *情境一.* 範圍(SCOPE)問題
Google對此範圍的說明`建立次要 Google 日曆,以及查看、建立、變更及刪除這類日曆的活動`,讓我以為不是`primary`的日曆(Calendar ID是自己mail的那個)就是次要日曆,但實際上好像不是,到現在我還是不知道次要日曆指的是哪種日曆。
- *情境二.* calendarId問題
程式給的calendarId,系統在登入的Google帳號日曆內找不到。
## 補充
### 帳戶存取權限
如果已經允許權限後,想要移除之前的存取權,可以到帳戶安全性移除。
[Google帳戶-安全性](https://myaccount.google.com/security)>[具有您帳戶存取權的應用程式](https://myaccount.google.com/permissions)
