見出し画像

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が起動できる」

というものでした。

画像1

違う、そうじゃない。

私がやりたいのは、
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」をクリックします。

画像2

表示された画面を下にスクロールすると、API Tokenが出てきます。

画像3

4-2. カレンダーID

サイトではカレンダーIDが

xxxxx@group.calendar.google.com

とされていましたが、Googleカレンダーのページで見てみたら、
アカウント名(私の場合は xxxxx@gmail.com )でよかったです。

4-3. 自動起動の設定

どこで何をすればいいのか迷ったのですが、
コードの編集画面で、
「編集」→「現在のプロジェクトのトリガー」を選択します。
私はなぜか英語環境ですが、言語設定が日本語なら、
日本語になっているはずです。

画像10

すると以下のような画面が出てきます。
設定内容は、以下の通りで良いかと思います。

「実行」を「watch」に指定。「時間主導型、分タイマー、15分ごと」

画像7

で、Saveを押したら、以下のように怒られました。
ここで「Advanced」を押します。日本語は「詳細設定」かな?

画像5

すると「Go to "プロジェクト名"」的なところがあるのでクリックします。

画像6

すると、やっとアクセス許可の画面にたどり着けるので、許可します。

画像7

しばらく待ったら、ちゃんとTogglで記入したタスクが
Googleカレンダーに流れ込んできた!すごい!

画像8

Google Apps Scriptの「My Projects」から
作ったプロジェクトを見てみると、
ちゃんと実行されているのが確認できた。すごい!

画像9

ということで、Google Apps Scriptを使って
Togglの記録を、自動でGoogleカレンダーに
流し込むことができるようになった。

作者のm-kawaguchiさん、ありがとうございます!

追記:自動更新が止まってしまうときの対応

2回ほど、自動更新が止まってしまっていることがありました。
m-kawaguchiさんの記事を見に行ったところ、
以下の記載がありました。

2018/4/6 追記:
検索エンジン等で、この記事を見て使ってくださっている方がいらっしゃいます。 導入をしてみたもののうまく動かないというフィードバックを時々いただきます。
動かない場合はまず、 Googleドライブに toggl_exporter_cache という前回実行した日時を記録するキャッシュファイルを作成していますが、 こちらの作成ができていることをご確認ください。
もし、作成できているようでしたら、一旦削除していただき、再実行を試みてください。 キャッシュを削除・再実行することで期待した動作をするケースが何例かありました。

とのことでしたので、やってみました。
Gドライブを開くと、確かにありました!"toggl_exporter_cache"が!

スクリーンショット 2020-12-16 9.03.19

こいつを右クリックから削除して、あとは待つだけ。
勝手に同期が再開されました。めでたしめでたし。
(追記ここまで)

追記2:Toggl APIのバージョンアップに対応(2024年11月)

なんか気がついたら動かなくなっていた。調べたところ、APIのバージョンがv8からv9に上がって、色々変更になっていた。

結論としては、Claudeさんに修正してもらったら動くようになった。
下にコード全文を貼りますので、コピペして、フロッピーディスクマーク💾を押してセーブしてください(なんで今どきフロッピー?)。

「xxxxxほにゃららxxxxx」となっている以下の値は、ご自分のものに変更してください。ってClaudeさんが言ってます。

  1. TOGGL_BASIC_AUTH: TogglのAPIトークン(Togglのプロフィール設定から取得可能)

  2. GOOGLE_CALENDAR_ID: 書き込み先のGoogleカレンダーID

  3. 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]);
  }
}


いいなと思ったら応援しよう!

haru
読んでいただいて、ありがとうございます! もしサポートしていただけると、とても喜びます。 ありがたく、パソコン関係グッズ購入費、 作業時のカフェオレ購入費、などに使わせていただきます。