カレンダーの同期アプリや連携サービスを試していた折、イベントが100個ほど複製されてしまいました…
あまりにも鬱陶しいので、重複したイベントを削除するアプリを作ろうと思い至りました。
その過程でサンプルをもとにカスタマイズする箇所も結構あったりして、どうせなのでまとめておこうと思います。
まずは、サンプルを動かす
https://developers.google.com/calendar/quickstart/go に動作するサンプルがあります。
まずは、これをもとにして、自分のカレンダーのイベントを一覧表示するところまでやってみます。
Step1 ~APIの有効化~
なにはともあれ、Google のサービスにプログラムからアクセスするための情報を取得します。
Step1 でその処理が簡単に行なえます。
本来であれば Google Developer Console で有効化するところを、簡略化してくれているようです。
「ENABLE THE GOOGLE CALENDAR API」ボタンを押します。
これで有効化されました。
ここで、「DOWNLOAD CLIENT CONFIGURATION」ボタンを押して、credentials.json という名前でプロジェクトディレクトリーに配置します。
ファイル名は複数形です。この名前のファイルを読み取るようなプログラムになっていますので、間違いのないように。
Step2 ~go get~
プロジェクトディレクトリーで、必要なパッケージを取得します。
過去に同パッケージを取得している場合でも、Go のバージョンを上げたり、パッケージの更新をしていないと、「undefined: proto.ProtoPackageIsVersion3」というエラーが出たりするので、ちゃんと実行しておきましょう。
go get -u google.golang.org/api/calendar/v3
go get -u golang.org/x/oauth2/google
Step3 ~サンプルをコピペ~
変なアレンジはせず、まずは動かすことを優先します。
サンプルのページにある内容を quickstart.go として保存します。
この時点で、プロジェクトディレクトリーには、credentials.json と quickstart.go があるはずです。
Step4 ~動かす~
go run quickstart.go
最初にコンソールに URL が出力されるので、それをブラウザのアドレスバーにコピペして、アプリのアクセスを承認します。
承認後、ブラウザにアクセスコードが表示されるので、今度はコンソールに貼り付けます。
すると、実行した日付以降の10個までのイベントが列挙されます。
その後
プロジェクトディレクトリーを見てみてください。
token.json というファイルが作成されているはずです。
これは、承認済みアプリに対して Google Calendar API にアクセスするための承認情報を記録したものです。
これがあれば、次回以降はアクセス承認をせずに API を実行できるようになります。
サンプルを理解する
quickstart.go を参照して、取得可能なイベントの属性と、処理の流れを確認します。
(実際にソースコードを片手に読んでもらえると理解しやすいかもしれません)
承認の処理は複雑なので、その部分をカスタマイズするまでお預けです。(token.json がある現状、あまり触れなくても不便はありませんし)
イベント取得の基本形
まずは、アプリの骨子であるイベント取得の流れを理解します。
イベント取得の基本形は以下のようになります。
main()
の中の、イベント取得の部分に注目しています。
- なにはともあれ、
calendar.New
で*calendar.Service
を取得する。 - イベントのリストは、
(*calendar.Service).List().(メソッドチェーン).Do()
で取得する。 -
events.Items
(型[]*calendar.Event
)で取得したイベントの情報を得る。
イベントに対してできることは、API ドキュメントの https://godoc.org/google.golang.org/api/calendar/v3#EventsService を参照すると、大体どんなものがあるかわかると思います。
いずれの操作でも、Delete()
や List()
の後に条件をメソッドチェーンの形で指定し、最後に Do()
を呼び出すと、その操作が実行できるようです。
また、List()
に渡している "primary"
という文字列ですが、これは承認したアカウントのデフォルトのカレンダーを意味する ID のようです。
複数のカレンダーを管理している場合など、別のカレンダーを指定したい場合は、ここを変更します。
カレンダーIDは、カレンダーごとの設定から確認できます。(概ね、hogehoge@group.calendar.google.com
のような形式になっています)
取得できるイベントの属性
上記の API ドキュメントをたどっていくと、取得できるイベントの属性が分かります。
これです→ https://godoc.org/google.golang.org/api/calendar/v3#Events
ただ、属性値自体が struct だったりして、たどっていくとキリがありません。
まあ、大体どんなものがあるかを把握するだけでいいと思います。
以下余談
ここらへんは、アプリの形に応じてどの程度詳細なところまで取得するべきか考えます。
今回つくりたいもので言えば、重複のチェックのためにイベントの属性を使いたいので…
- 複数の属性を連結できる形に加工したい (連結して単一のキーにすると重複チェックが楽)
- 本当に重複しているか、デバッグ出力したい
- 反対に、「特定の属性値を持つイベントを抽出する」ことは不要なので、人間が入力する手間は考えなくて良い
というのを満たせれば良いです。
というわけで、今回は文字列に変換することを念頭に置きます。
カスタマイズする
quickstart.go をカスタマイズして、今回やりたいことである重複したイベントを削除する機能を追加していきます。
ついでに、アプリとしての体を成すようにしていきます。
重複したイベントを除去する
イベントから、キーとする属性値を文字列化する
こんな関数を用意して、*calendar.Event
を文字列にするようにしました。
↓引数fields
には、キーとする属性の名前を複数指定します。
func UniqKey(e *calendar.Event, fields ...string) string {
k := ""
for _, f := range fields {
switch strings.ToLower(f) {
case "created":
k += e.Created
case "description":
k += e.Description
case "end":
t := e.End.DateTime
if t != "" {
k += t
} else {
k += e.End.Date
}
case "etag":
k += e.Etag
case "hangoutLink":
k += e.HangoutLink
case "htmllink":
k += e.HtmlLink
case "icaluid":
k += e.ICalUID
case "id":
k += e.Id
case "location":
k += e.Location
case "start":
t := e.Start.DateTime
if t != "" {
k += t
} else {
k += e.Start.Date
}
case "summary":
k += e.Summary
case "updated":
k += e.Updated
default:
}
}
return k
}
重複の検出をする
重複検知の簡単な実装としては、map があります。
今回キーは文字列ですので、map[string]struct{}
を使います。
uniqs := make(map[string]struct{})
:
key := UniqKey(item, "Description", "Summary", "Start", "End")
if _, found := uniqs[key]; found {
fmt.Printf("[DEL] %v (%v)\n", item.Summary, date)
// イベントを削除する
} else {
fmt.Printf("* %v (%v)\n", item.Summary, date)
uniqs[key] = struct{}{}
}
重複した場合でも優先して残すべき条件がある場合は、map[string]([]*calendar.Event)
のようにしておくと、後で処理がしやすいでしょう。
イベントを削除する
ここまでで srv.Events.List()
を使ってきましたが、削除は srv.Events.Delete()
を使います。
List()
の場合と同様、Delete()
で条件を指定して、最後に Do()
を呼び出します。
uniqs := make(map[string]struct{})
:
key := UniqKey(item, "Description", "Summary", "Start", "End")
if _, found := uniqs[key]; found {
fmt.Printf("[DEL] %v (%v)\n", item.Summary, date)
delevent := srv.Events.Delete("primary", item.Id)
err = delevent.Do()
if err != nil {
fmt.Printf(" failed to delete: %v", err)
}
} else {
fmt.Printf("* %v (%v)\n", item.Summary, date)
uniqs[key] = struct{}{}
}
ただし、これだけでは削除できません。
ソースコード上でのスコープの変更と、トークンの再取得が必要です。
スコープを変更する
スコープは、いわば、承認時に申請しておく権限のようなものです。
ここになって少し承認の部分を変更します。
サンプルのコードでは、以下のようになっています。
config, err = google.ConfigFromJSON(b, calendar.CalendarReadonlyScope)
あからさまに削除等の操作ができないことがわかるスコープになっていますね。
API ドキュメントを CalendarReadonlyScope
で検索するとわかりますが、イベントのみ操作可能なスコープや全体を変更可能なスコープがあります。
https://godoc.org/google.golang.org/api/calendar/v3#pkg-constants
このスコープに応じて、承認時のメッセージが変わってきます。
いちいち考えるのが面倒なので何でもできそうなCalendarScopeにします。
アプリの用途を満たす最低限のスコープを指定するようにしてください。
config, err = google.ConfigFromJSON(b, calendar.CalendarScope /*CalendarReadonlyScope*/)
なお、上記のコード変更で承認時に必要な情報が変わっています。
今までのトークンは使えませんので、ファイル token.json は削除し、再度承認をします。
承認をちょっとだけ自動化する
コピペが2回も必要なのは使い勝手が悪いので、できる範囲で自動化します。
サンプル quickstart.go の getTokenFromWeb()
の実装を変更します。
承認URLをブラウザで表示する
これは非常に簡単で、外部のパッケージ github.com/pkg/browser
を使うだけでした。
承認ページのURLは変数 authURL
に格納されていますので、それを引数に上記パッケージの関数を呼び出します。
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
browser.OpenURL(authURL)
これで、最初のコピペが不要になります。
次に、アクセスコードをアプリ(コンソール)にコピペする部分を自動化します。
アプリ内にHTTPサーバーを実装してアクセスコードを受け取る
承認の流れで(OAuth2)、アクセスコードを含むリクエストを受け取るためのリダイレクトURLを指定することができます。
今回はこの仕組みを使い、Google の承認ページからアプリがサーブするサイトにリダイレクトさせます。
まずは、authURL
を取得する前に、リダイレクト先を指定します。
config.RedirectURL = fmt.Sprintf("http://localhost:%d/", 7878) //これ。ポートは暫定的に7878にする。
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
browser.OpenURL(authURL)
当然ながら、これだけでは動作しません。
アプリ内にHTTPサーバーの機能を実装しないといけません。
HTTPサーバーを動作させつつ、承認の手続きをすすめるため、goroutine を利用します。
HTTPサーバーで受け取ったアクセスコードは、チャネル経由で承認手続きをしているコードに繋げます。
// 簡易HTTPサーバー
// 変数 code, codeChan に注目してください。他はお飾りです。
func launchRedirectionServer(port uint16, codeChan chan string) {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
code := r.FormValue("code")
codeChan <- code
var color string
var icon string
var result string
if code != "" {
//success
color = "green"
icon = "✓"
result = "Successfully authenticated!!"
} else {
//fail
color = "red"
icon = "✘"
result = "FAILED!"
}
disp := fmt.Sprintf(`<div><span style="font-size:xx-large; color:%s; border:solid thin %s;">%s</span> %s</div>`, color, color, icon, result)
fmt.Fprintf(w, `
<html>
<head><title>%s pomi</title></head>
<body onload="open(location, '_self').close();"> <!-- Chrome won't let me close! -->
%s
<hr />
<p>This is a temporal page.<br />Please close it.</p>
</body>
</html>
`, icon, disp)
})
http.ListenAndServe(fmt.Sprintf(":%d", port), nil)
}
// 承認手続き
func getTokenFromWeb(config *oauth2.Config) (*oauth2.Token, error) {
var codeChan chan string
config.RedirectURL = fmt.Sprintf("http://localhost:%d/", 7878)
codeChan = make(chan string)
go launchRedirectionServer(7878, codeChan)
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
browser.OpenURL(authURL)
var authCode string
authCode = <-codeChan
// 以上がカスタマイズ箇所
tok, err := config.Exchange(context.TODO(), authCode)
:
}
承認手続きの中で変数 authCode
を fmt.Scan
していた箇所がなくなり、HTTPサーバーから来るチャネルの読み取りに変わっています。
そして、最後の config.Exchange()
で、アクセスコードをもとにトークンを取得していますね。
なお、割り切りポイントとして、HTTPサーバーの停止は実装していません。
考えるのが面倒だったんで 常駐せず実行後は直ぐに終了するアプリですので、そこまでする必要はないかな、と。
ただ、そのために、main() で正常終了時も os.Exit(0)
を呼ぶ必要がありました。
ちゃんとした CLI アプリにする
以下の対応をしました。
- キーとする項目を指定できるようにする。
- 重複チェックの開始日と取得件数、カレンダーIDを指定できるようにする。
- Dry Runモードをつける。(削除をせずコンソールへの出力のみを行う)
- 承認時のローカルポートを指定できるようにする。
- ヘルプメッセージをつける。
ここらへんは、好きな CLI フレームワークを使えばいいと思います。
個人的には、自分の作った gli というパッケージが死ぬほど使いやすいので使っています。
// オプションの定義 (サブコマンドも定義可能ですが、今回はナシ)
type globalCmd struct {
Start gli.Date `cli:"start,s=DATE" help:"defaults to today"`
Items int64 `cli:"items,n=NUMBER" default:"10" help:"the number of events from --start"`
Keys gli.StrList `cli:"keys,k=LIST_OF_STRINGS" default:"Description,Summary,Start,End" help:"comman-separated keys to test uniquity of events"`
CalendarID string `cli:"calendar-id,id" default:"primary"`
Credential string `cli:"credentials,c=FILE_NAME" default:"./credentials.json" help:"your client configuration file from Google Developer Console"`
Token string `cli:"token,t=FILE_NAME" default:"./token.json" help:"file path to read/write retrieved token"`
AuthPort uint16 `cli:"auth-port=NUMBER" default:"7878"`
DryRun bool `cli:"dry-run,dry" help:"do not exec"`
}
// 文字列で表現できないデフォルト値は *cmd.Init() の中で代入。gli が勝手に呼び出す。
func (c *globalCmd) Init() {
c.Start = gli.Date(time.Now())
}
// main() はコマンドラインの初期化だけになる。
func main() {
app := gli.NewWith(&globalCmd{})
app.Name = "uniqal"
app.Desc = "make each event be unique" //英語自信ない...
app.Version = "0.1.0"
app.Usage = `uniqal --credential=./my_credentials.json --items=100 --start=` + time.Now().AddDate(0, 0, 7).Format("2006-01-02")
app.Copyright = "(C) 2019 Shuhei Kubota"
err := app.Run(os.Args)
if err != nil {
os.Exit(1)
}
os.Exit(0) //ワークアラウンド: HTTPサーバーを停止させたい。
}
// メインのルーチンはこちらに移動。gli が勝手に呼び出す。
func (c globalCmd) Run() error {
uniqs := make(map[string]struct{})
var config *oauth2.Config
var err error
if _, err := os.Stat(c.Credential); err != nil { // c.オプション名 でコマンドライン引数を参照
:
}
出来上がったものがこちらになります
ビルドした上で、自前の credentials.json を配置すれば使えるようになります。
カレンダー同期・連携サービスを使って自爆した人にオススメです。