カレンダーと Notion タスクを同期する : Notion 解説(32)

1. はじめに

以前、Google Apps Script (以下、GAS と記述します) のカレンダートリガーで、Notion タスクの登録する処理を作成しました。ただ、これは問題点があって、カレンダーデータを更新してもタスクが作成されてしまい、余計な Notion タスクを消す作業に追われました。Notion page の ID さえわかれば、作成ではなくプロパティなどの更新ができるので、カレンダーのメモに ID を記録することで、作成・更新を切り替えられるようにしたので、そのスクリプトを解説します。GAS を始めて使う人もいると思うので、過去に書いた記事を参照しながら、説明します。ただ、スクリプトは当時とだいぶ変わっているので、ここに全て記録することにします。

2. GAS の準備

まだ GAS を使ったことがない人は、以下の記事を参考にスクリプトエディタを開いてください。スクリプトはまだ記述しません。

hkob.hatenablog.com

3. Calendar API の準備

まだ、Calendar API を有効にしていない人は、以下のページを参考に有効化してください。「Google Calendar API を追加」の画像の部分まで進めてください。

hkob.hatenablog.com

4. API の接続テストと認証の取得

コードをたくさん記述する前に Calendar API がうまく動作するかを確認します。スクリプトエディタに以下のコードを記述します。3 で取得したカレンダーID を下から4行目の部分に記述してください。

// 前回保存したカレンダーのSyncTokenを取り出す、前回保存分が無い場合は今回のSyncTokenを利用する
function getSyncToken(calendarId){
  var token = PropertiesService.getScriptProperties().getProperty('SYNC_TOKEN')
  if (token) {
    return token
  }
  let events = Calendar.Events.list(calendarId, {'timeMin': (new Date()).toISOString()})
  return events.nextSyncToken
}

// 初回認証のためだけのテスト呼び出し関数
function readCalendar() {
  let calendarId = "この部分にカレンダーIDを書きます。";
  let token = getSyncToken(calendarId);
  Logger.log(token);
}

初回だけ認証が必要なので、下にあるように readCalendar を選択した状態で、「実行」をしてください。許可を行って文字列がコンソールに出力されたら成功です。終わったら readCalender 関数は消してしまってよいです。

f:id:hkob:20210515222907p:plain
readCalendar を実行

5. タスクデータベースID とNotion token の保存

次に Notion の Token とタスクデータベースの ID を環境変数に記録しておきます。見えるところに ID などを記録しておきたくないためです。storeTokenAndId の MY_NOTION_TOKEN と DATABASE_ID の説明の部分にそれぞれを記述します。先ほどと同様に storeTokenAndId を実行して、実行ログにそれぞれが表示されたら成功です。こちらも終わったら、storeTokenAndId 自体を消すか、記録した ID を消してしまってください。

// MY_NOTION_TOKEN と DATABASE_ID をプロパティに登録する(1回だけ使い、終わったらIDを消す)
function storeTokenAndId() {
  const scriptProperties = PropertiesService.getScriptProperties()
 scriptProperties.setProperties({
    "MY_NOTION_TOKEN": "ここに「Notion Token」を記述",
    "DATABASE_ID": "ここに「データベースID」を記述"
  })
  // 登録できたことを確認
  console.log("myNotionToken = " + myNotionToken())
  Logger.log("databaseId = " + databaseId())
}

// MY_NOTION_TOKEN を取得
function myNotionToken() {
  return PropertiesService.getScriptProperties().getProperty("MY_NOTION_TOKEN")
}

// DATABASE_ID を取得
function databaseId() {
  return PropertiesService.getScriptProperties().getProperty("DATABASE_ID")
}

6. その他の関数の準備

メイン以外のユーティリティ関数を記述しておきます。これまでに説明したものもありますが、軽く解説しておきます。

6.1 sendNotion: Notion に payload を送信

いつもの Notion に payload を送る関数です。以前は url や method を直接書いていましたが、共通化のために外から渡すように変更しています。

// Notion に payload を send する
function sendNotion(url, payload, method){
  let options = {
    "method" : method,
    "headers": {
      "Content-type": "application/json",
      "Authorization": "Bearer " + myNotionToken(),
      "Notion-Version": "2021-05-13",
    },
    "payload" : JSON.stringify(payload)
  };
  // デバッグ時にはコメントを外す
  // Logger.log(options)
  return JSON.parse(UrlFetchApp.fetch(url, options))
}

6.2 createPage: ページを作成

ページを作成する関数です。先ほどの sendNotion にページ作成の url と post を渡しているだけです。

function createPage(payload) {
  return sendNotion("https://api.notion.com/v1/pages", payload, "POST")
}

6.3 patchNotion: Notion に payload をパッチ

ページを更新する関数です。同じく page を指定した url と patch を渡しています。

function updatePage(pageId, payload) {
  let url = "https://api.notion.com/v1/pages/" + pageId // API URL
  sendNotion(url, payload, "PATCH")
}

6.4 saveSyncToken: syncToken をプロパティに保存

新しく作成・変更されたイベントを取得するには、保存されている syncToken と比較して、それより新しいものを取得します。このため、作業が終わったらその時点の syncToken をプロパティに保存しておきます。そのための関数です。

// SyncTokenをプロパティに保存する
function saveSyncToken(token){
  PropertiesService.getScriptProperties().setProperty('SYNC_TOKEN', token)
}

6.5 eventToHash: カレンダーイベントから hash を作成

取得したイベントから payload 用のハッシュに変換する関数です。日付が設定されていない場合には、null を返します。

// カレンダーイベントから日付の hash  を作成 (削除時にも呼ばれるので、イベントに日付が設定されていなければ null を返却)
function eventToHash(e) {
  let hash = null
  if ('dateTime' in e.start){
    hash = {
      "start": e.start.dateTime.replace("T", " "),
      "end": e.end.dateTime.replace("T", " ")
    }
  }
  if ('date' in e.start) {
    hash = {
      "start": e.start.date
    }
  }
  return hash
}

6.6 createPayload: 作成用の payload を作成

title と 6.5 で作成した日付 hash から作成用の payload を生成します。

// title と date_hash から create payload を作成
function createPayload(title, date_hash) {
  return {
    "parent": {
      "database_id": databaseId()
    },
    "properties": {
      "タスク名": {
        "title": [
          {
            "text": {
              "content": title
            }
          }
        ]
      },
      "日付": {
        "type": "date",
        "date": date_hash
      },
    }
  }
}

6.7 updatePayload: 更新用の payload を作成

こちらは更新用の payload 作成関数です。

// title と date_hash から update payload を作成
function updatePayload(title, date_hash) {
  return {
    "properties": {
      "日付": {
        "date": date_hash
      },
      "タスク名": {
        "title": [
          {
            "text": {
              "content": title
             }
          }
        ]
      }
    }
  }
}

7. メイン関数の作成

カレンダーイベントの description の最終行を取得し、「id:」で始まった場合には、その後ろの id を取得し、ページ更新するようにしています。存在しなかった場合にはタスクを生成した上で、カレンダーの description に id を記録します。

// カレンダーイベントが変更されたら呼ばれるメソッド
function doCalendarPost(event) {
  let calendarId = event.calendarId // カレンダーIDのの取得
  let token = getSyncToken(calendarId) // 前回実行時に取得したカレンダーTokenの取得
  let events = Calendar.Events.list(calendarId, {'syncToken': token}) // Token から後のカレンダーを取得
  let filteredItems = events.items.filter(e => {return e.status == "confirmed"}) // ステータスを見て登録、もしくは更新の予定のみにフィルタリング
  filteredItems.forEach(e => {
    let descriptions = (e.description || "").split("\n")
    console.log(descriptions)
    let dlen = descriptions.length
    let exist = false
    let id = '-'
    if (dlen > 0) { // 行が存在する場合
      last_line = descriptions[dlen-1] // 最後の行を取得
      let ids = last_line.match(/^id:(.*)$/) // id を取得
      if (ids != null) { // 取得できた場合
        id = ids[1]; // 取得した id
        exist = true
      }
    }
    let date_hash = eventToHash(e) // イベント日付を取得
    if (date_hash) { // イベントに日付がある場合だけ実施
      if (exist) { // 存在した場合は update
        let payload = updatePayload(e.summary, date_hash)
        updatePage(id, payload)
        Utilities.sleep(1000);
      } else {
        let payload = createPayload(e.summary, date_hash)
        let ans = createPage(payload)
        Utilities.sleep(1000);
        id = ans["id"]
        descriptions.push("id:" + id)
        e.description = descriptions.join("\n")
        Calendar.Events.patch(e, calendarId, e.id)
      }
    }
  })
  saveSyncToken(events.nextSyncToken) // 今回のTokenを保存する(次回のScript実行時に利用)
}

8. テスト

Google Calendar 上でテストしてみました。すでに Twitter で報告したようにこんな感じでタイトル変更・時間変更などうまく動いているようです。

9. おわりに

Web 上の Google Calendar からはこれでうまく動作しました。ただ、Alfred から「ac タスク名」とした場合に、タスクが二重化する現象が発生してしまいました。作成時にタスクが作成され、description 保存時に再度イベントが発行され、タイミングにより description の id が読み込まれずに再度作成されてしまうのかもしれません。遅延を入れたりしたのですが、それでもたまに失敗するので、Alfred 側を修正することにしました。これは別記事にします。


はてなブログに書いた Notion 記事一覧