TogglのデータをGoogleカレンダーに記録する方法
時間測定、してますか?
私は、時間測定ツールとしてTogglを使っています。
Tooglに記入した内容がGoogleカレンダーに自動で転記されれば、
後で振り返ったときに、この日は何をやっていたのか一目瞭然なので、
便利っぽいですね。
ということでTooglとGoogleカレンダーを連携しようと思い立ちました。
早速ググってみたら、いくつか連携の方法が見つかりました。
1. Zapier
2. TogglのGoogleカレンダー連携ツール
3. Integromat
4. Google Apps Script
結論から言うと、4. を選択しました。
以下にその理由を書いていきますが、
急いでる人は4. まで飛んでいってください。
1. Zapier
やりたいことはできそうなのですが、
無料アカウントでは月に100タスクしか実行できません。
つまり、
Togglに一日3タスク以上登録する場合、
無料プランではオーバーしてしまう可能性が高いです。
私は間違いなく超えてしまうので、×です。
2. TogglのGoogleカレンダー連携ツール
Togglのサイトを見てみたら、Googleカレンダーとの
連携のツールを出しています。
なんだ、これでいいじゃん、と見てみたら、
「Googleカレンダーのイベント画面に、
Togglのちっこいアイコンが表示されるので、
そこからTogglが起動できる」
というものでした。
違う、そうじゃない。
私がやりたいのは、
Togglの記録をGoogleカレンダーに送ること
なのであって、方向が逆なので×です。
3. Integromat
これもZapierと同じようなサービス間連携ツールです。
しかし、これまた無料プランは100タスクしか実行できないので、
Zapierと同じ理由で×です。
4. Google Apps Scriptを使う
以下で、m-kawaguchiさんが、自作のGoogle Apps Scriptで
Togglの記録をGoogleカレンダーに保存する方法を
公開してくださっている。
これはバッチリ私のやりたいことができそう。
ということで、ありがたく使わせてもらうことにした。
基本的に、サイトの記載に従っていけばいいのだが、
いくつかつまづいたところがあったので、メモしておく。
2024年11月追記)Toggl APIに更新があったようなので、コードはこの記事一番下のものを使ってください。
4-1. Toggl API Tokenの表示
まず詰まったのが、TogglのAPI Tokenを表示させるところ。
まずTogglのサイトにブラウザでログインします。
画面左下のアカウント名をクリックすると
ポップアップが出てくるので、
「Profile settings」をクリックします。
表示された画面を下にスクロールすると、API Tokenが出てきます。
4-2. カレンダーID
サイトではカレンダーIDが
とされていましたが、Googleカレンダーのページで見てみたら、
アカウント名(私の場合は xxxxx@gmail.com )でよかったです。
4-3. 自動起動の設定
どこで何をすればいいのか迷ったのですが、
コードの編集画面で、
「編集」→「現在のプロジェクトのトリガー」を選択します。
私はなぜか英語環境ですが、言語設定が日本語なら、
日本語になっているはずです。
すると以下のような画面が出てきます。
設定内容は、以下の通りで良いかと思います。
で、Saveを押したら、以下のように怒られました。
ここで「Advanced」を押します。日本語は「詳細設定」かな?
すると「Go to "プロジェクト名"」的なところがあるのでクリックします。
すると、やっとアクセス許可の画面にたどり着けるので、許可します。
しばらく待ったら、ちゃんとTogglで記入したタスクが
Googleカレンダーに流れ込んできた!すごい!
Google Apps Scriptの「My Projects」から
作ったプロジェクトを見てみると、
ちゃんと実行されているのが確認できた。すごい!
ということで、Google Apps Scriptを使って
Togglの記録を、自動でGoogleカレンダーに
流し込むことができるようになった。
作者のm-kawaguchiさん、ありがとうございます!
追記:自動更新が止まってしまうときの対応
2回ほど、自動更新が止まってしまっていることがありました。
m-kawaguchiさんの記事を見に行ったところ、
以下の記載がありました。
とのことでしたので、やってみました。
Gドライブを開くと、確かにありました!"toggl_exporter_cache"が!
こいつを右クリックから削除して、あとは待つだけ。
勝手に同期が再開されました。めでたしめでたし。
(追記ここまで)
追記2:Toggl APIのバージョンアップに対応(2024年11月)
なんか気がついたら動かなくなっていた。調べたところ、APIのバージョンがv8からv9に上がって、色々変更になっていた。
結論としては、Claudeさんに修正してもらったら動くようになった。
下にコード全文を貼りますので、コピペして、フロッピーディスクマーク💾を押してセーブしてください(なんで今どきフロッピー?)。
「xxxxxほにゃららxxxxx」となっている以下の値は、ご自分のものに変更してください。ってClaudeさんが言ってます。
TOGGL_BASIC_AUTH: TogglのAPIトークン(Togglのプロフィール設定から取得可能)
GOOGLE_CALENDAR_ID: 書き込み先のGoogleカレンダーID
WORKSPACE_ID: TogglのワークスペースID(Togglのワークスペース設定から確認可能)
また、初めて実行する人は、Google Apps Scriptプロジェクトで以下の権限を有効にする必要があります:
カレンダーAPI
ドライブAPI
URL Fetch
/*
Toggl time entries export to GoogleCalendar
author: Masato Kawaguchi
Released under the MIT license
version: 1.0.4
https://github.com/mkawaguchi/toggl_exporter/blob/master/LICENSE
required: moment.js
project-key: 15hgNOjKHUG4UtyZl9clqBbl23sDvWMS8pfDJOyIapZk5RBqwL3i-rlCo
*/
var CACHE_KEY = 'toggl_exporter:lastmodify_datetime';
var TIME_OFFSET = 9 * 60 * 60; // JST
var TOGGL_API_HOSTNAME = 'https://api.track.toggl.com/api/v9';
var TOGGL_BASIC_AUTH = 'xxxxxここにあなたのTogglAPIトークンxxxxx:api_token';
var GOOGLE_CALENDAR_ID = 'xxxxxここにあなたのGoogleカレンダーIDxxxxx@group.calendar.google.com';
var WORKSPACE_ID = 'xxxxxここにあなたのTogglワークスペースIDxxxxx'; // Togglのワークスペース設定から確認できます
// 共通のヘッダー設定
var headers = {
"Authorization": "Basic " + Utilities.base64Encode(TOGGL_BASIC_AUTH),
"Content-Type": "application/json"
};
function getLastModifyDatetime() {
var cache = {};
var file = DriveApp.getFilesByName('toggl_exporter_cache');
if(!file.hasNext()) {
var now = Moment.moment().format('X');
var beginning_of_day = parseInt(now - (now % 86400 + TIME_OFFSET), 10).toFixed();
putLastModifyDatetime(beginning_of_day);
return beginning_of_day;
}
file = file.next();
var data = JSON.parse(file.getAs("application/octet-stream").getDataAsString());
return parseInt(data[CACHE_KEY], 10).toFixed();
}
function putLastModifyDatetime(unix_timestamp) {
var cache = {};
cache[CACHE_KEY] = unix_timestamp;
var file = DriveApp.getFilesByName('toggl_exporter_cache');
if(!file.hasNext()) {
DriveApp.createFile('toggl_exporter_cache', JSON.stringify(cache));
return true;
}
file = file.next();
file.setContent(JSON.stringify(cache));
return true;
}
function getTimeEntries(unix_timestamp) {
var startDate = Moment.moment(unix_timestamp, 'X').toISOString();
var uri = TOGGL_API_HOSTNAME + '/me/time_entries' +
'?start_date=' + encodeURIComponent(startDate) +
'&end_date=' + encodeURIComponent(Moment.moment().toISOString());
Logger.log("Requesting time entries with URI: " + uri);
try {
var response = UrlFetchApp.fetch(
uri,
{
'method': 'GET',
'headers': headers,
'muteHttpExceptions': true
}
);
Logger.log("Time Entries Response Code: " + response.getResponseCode());
Logger.log("Time Entries Response Headers: " + JSON.stringify(response.getAllHeaders()));
var contentText = response.getContentText();
Logger.log("Time Entries Response Content (first 500 chars): " + contentText.substring(0, 500));
if (response.getResponseCode() === 200) {
return JSON.parse(contentText);
} else {
throw new Error("Time entries API request failed with status " + response.getResponseCode());
}
} catch (e) {
Logger.log(["Time entries fetch error", unix_timestamp, e.toString()]);
return null;
}
}
function getProjectData(project_id) {
if (!project_id) return {};
var uri = TOGGL_API_HOSTNAME + '/workspaces/' + WORKSPACE_ID + '/projects/' + project_id;
Logger.log("Requesting project data with URI: " + uri);
try {
var response = UrlFetchApp.fetch(
uri,
{
'method': 'GET',
'headers': headers,
'muteHttpExceptions': true
}
);
Logger.log("Project Data Response Code: " + response.getResponseCode());
Logger.log("Project Data Response Content: " + response.getContentText());
if (response.getResponseCode() === 200) {
var projectData = JSON.parse(response.getContentText());
return projectData.data || projectData; // v9 APIの応答形式に対応
} else {
throw new Error("Project data fetch failed with status " + response.getResponseCode());
}
} catch (e) {
Logger.log(["getProjectData error", project_id, e.toString()]);
return {};
}
}
function recordActivityLog(description, started_at, ended_at, tags) {
try {
var calendar = CalendarApp.getCalendarById(GOOGLE_CALENDAR_ID);
if (!calendar) {
throw new Error("Calendar not found with ID: " + GOOGLE_CALENDAR_ID);
}
calendar.setTimeZone('Asia/Tokyo');
var options = {
'description': 'tags: ' + tags
};
var event = calendar.createEvent(description, new Date(started_at), new Date(ended_at), options);
Logger.log("Successfully created calendar event: " + description);
return event;
} catch (e) {
Logger.log(["recordActivityLog error", description, e.toString()]);
return null;
}
}
function watch() {
try {
Logger.log("Starting watch function...");
var check_datetime = getLastModifyDatetime();
Logger.log("Last modify datetime: " + check_datetime);
Logger.log("Last modify datetime in readable format: " + Moment.moment(check_datetime, 'X').format());
var time_entries = getTimeEntries(check_datetime);
Logger.log("Retrieved time entries: " + (time_entries ? time_entries.length : 0));
if (time_entries) {
var last_stop_datetime = null;
for (var i = 0; i < time_entries.length; i++) {
var record = time_entries[i];
if (record.stop == null) {
Logger.log("Skipping entry without stop time: " + JSON.stringify(record));
continue;
}
var project_data = getProjectData(record.pid);
var project_name = project_data.name || '';
var activity_log = [(record.description || '名称なし'), project_name].filter(function(e){return e}).join(" : ");
var tags = record.tags || ['なし'];
Logger.log("Processing entry: " + activity_log);
Logger.log("Start: " + record.start + ", Stop: " + record.stop);
var event = recordActivityLog(
activity_log,
Moment.moment(record.start).format(),
Moment.moment(record.stop).format(),
tags.join()
);
if (event) {
last_stop_datetime = record.stop;
Logger.log("Successfully recorded activity: " + activity_log);
}
}
if (last_stop_datetime) {
var new_timestamp = (parseInt(Moment.moment(last_stop_datetime).format('X'), 10) + 1).toFixed();
Logger.log("Updating last modify datetime to: " + new_timestamp);
Logger.log("New datetime in readable format: " + Moment.moment(new_timestamp, 'X').format());
putLastModifyDatetime(new_timestamp);
}
}
Logger.log("Watch function completed successfully");
} catch (e) {
Logger.log(["watch function error", e.toString(), e.stack]);
}
}