Google Calendarの予定一覧を今日の日報に流し込む

日報を書くとき、カレンダーの今日の予定一覧を見ながら書くことが多いと思います。しかし、カレンダーの予定を毎回日報にコピペするのは面倒。日報に最初から予定の一覧が流し込まれていれば、もっと楽をできる! ということでやってみました。私の場合、esa.ioに日報を書いていますが、APIが使えるサービスなら大体同じことができると思います。

当日の0:00頃に以下のような形で、今日の予定一覧が日報に流し込まれてきます。

f:id:syou6162:20210312110843p:plain
今日の予定一覧がesa.ioに流し込まれてくる

## 本日の予定
- 健康診断
  - 時間: 9:00 ~ 12:00
  - 説明: 病院のURLがここに
- XXX勉強会
  - 時間: 19:00 ~ 20:00
  - メンバー: syou6162, xxx, yyy
- ...

やり方: Google App Scriptを使う

認証回りや実行環境で悩みたくなかったので、Google App Script(GAS)を使います。GASは5年前くらいに少し使ったことがあったけど、Web上のエディタでよく分からないSDKを触るのはあまり体験がよくなかったんですが、最近はとてもよくなっていました。手元のエディタ(VSCode)でTypeScriptの型の恩恵を受けながら、スムーズに書くことができました。人権が存在していた。

GASには定期実行するトリガーがありますが、dailyだと「0~1時の間に実行」という形でしか指定できません。これだと微妙に困るので、こんな感じで回避しています。マジかよ...という感じですが、ググったところよくある回避方法のようです。

  • 前日の23時~0時に「トリガーを登録するdailyのトリガー」を実行
    • 当日の0:00に実行されるトリガーを登録する。一度きりのトリガーだと、実行時間で分まで指定でできる
  • 当日の0:00にトリガーが実行され、予定一覧がesa.ioに流し込まれる
    • 一度きりのトリガーがゴミとして残ってしまうので、実行前に不要なトリガーの掃除をする

外部サービスに依存する場合、失敗の通知などの考慮も必要になってきますが、GASではディフォルトで通知してくれるのでこれまた便利でした。

コード

コードはこんな感じ。100行くらいで書けるのでお手軽だし、claspのお陰でgithubでの管理もできる。

const getEvents = (): GoogleAppsScript.Calendar.CalendarEvent[] => {
    const calendar = CalendarApp.getDefaultCalendar()
    const now = new Date()
    const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0)
    const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59)
    return calendar.getEvents(today, tomorrow)
}

const event2HourAndMinute = (d: GoogleAppsScript.Base.Date): string => {
    return Utilities.formatDate(d, "Asia/Tokyo", "HH:mm")
}

const event2TimeString = (e: GoogleAppsScript.Calendar.CalendarEvent): string => {
    return `${event2HourAndMinute(e.getStartTime())} ~ ${event2HourAndMinute(e.getEndTime())}`
}

const event2MembersString = (e: GoogleAppsScript.Calendar.CalendarEvent, maxMembers: number): string => {
    const members = e.getGuestList()
    if (members.length == 0) {
        return ""
    }
    return members.slice(0, Math.min(members.length - 1, maxMembers)).map((g: GoogleAppsScript.Calendar.EventGuest) => {
        if (g.getName() != "") {
            return g.getName()
        } else {
            return g.getEmail()
        }

    }).join(", ")
}

const todayEvents2String = (): string => {
    const events = getEvents()
    if (events.length == 0) {
        return ""
    }
    let str = ""
    str += "## 本日の予定\n"
    events.forEach((calendarEvent: GoogleAppsScript.Calendar.CalendarEvent) => {
        str += `- ${calendarEvent.getTitle()}\n`
        str += `  - 時間: ${event2TimeString(calendarEvent)}\n`

        // 一行に抑えたいので、改行を置換する
        const description = calendarEvent.getDescription().replace(/\r?\n/g, " ")
        if (description != "") {
            str += `  - 説明: ${description}\n`
        }

        const membersStr = event2MembersString(calendarEvent, 3)
        if (membersStr != "") {
            str += `  - メンバー: ${membersStr}\n`
        }
    })
    return str
}

function formatDate(dt: Date): string {
    const y = dt.getFullYear();
    const m = ('00' + (dt.getMonth() + 1)).slice(-2);
    const d = ('00' + dt.getDate()).slice(-2);
    return (y + '/' + m + '/' + d);
}

const teamName = PropertiesService.getScriptProperties().getProperty("esaTeamName")
const token = PropertiesService.getScriptProperties().getProperty("esaAPIToken")
const apiBaseUrl = 'https://api.esa.io/v1/teams/' + teamName

const postEsa = (content: string) => {

    const payload = {
        "post": {
            "name": "日報",
            "category": `日報/${formatDate(new Date())}`,
            "body_md": content,
            "wip": false,
            "message": "Update by GAS"
        }
    }

    const url = apiBaseUrl + "/posts"
    const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
        method: "post",
        contentType: 'application/json; charset=utf-8',
        headers: { 'Authorization': 'Bearer ' + token },
        payload: JSON.stringify(payload)
    }
    const resp = UrlFetchApp.fetch(url, options)
    console.log(`${resp.getResponseCode()}`)
    console.log(resp.getContentText())
}

// 一回だけ作るトリガー
function setOnceTrigger() {
    ScriptApp.newTrigger("setTrigger").timeBased().atHour(23).everyDays(1).create();
}

function setTrigger() {
    const today = new Date()
    const tomorrow = new Date(today)
    tomorrow.setDate(tomorrow.getDate() + 1)
    tomorrow.setHours(0)
    tomorrow.setMinutes(0)
    ScriptApp.newTrigger('main').timeBased().at(tomorrow).create();
}

const cleanUnusedTriggers = () => {
    const triggers = ScriptApp.getProjectTriggers()
    triggers.forEach((t => {
        if (t.getHandlerFunction() == "main") {
            console.log(t.getEventType.toString())
            console.log(t.getHandlerFunction())
            console.log(t.getTriggerSourceId())
            ScriptApp.deleteTrigger(t)
        }
    }))
}

const main = () => {
    cleanUnusedTriggers()
    postEsa(todayEvents2String())
}

APIのTokenをコードに直接書きたくなかったので、プロパティを使いたかったんだけど、新UIでは設定する箇所がまだなかった。ここだけ困ったので、早く追加されて欲しい~。

参考