自動化
PR

TimeTreeの予定をGoogleカレンダーへ一括同期・連携する方法【無料エクスポート】

sanane
記事内に商品プロモーションを含む場合があります

はじめに

本記事では、TimeTreeに登録した予定をGoogleカレンダーへ一括で同期・エクスポートする方法を、無料で使えるブックマークレットとGoogle Apps Script(GAS)を使って解説します。

「TimeTreeとGoogleカレンダーを連携させたい」「過去の予定をまとめてGoogleカレンダーに移したい」「TimeTreeのエクスポート機能では物足りない」という方に向けたツールです。指定した月範囲のイベントをワンクリックで一括転送でき、同じ予定が二重登録されないよう自動で防止する仕組みも入っています。

共有カレンダーはTimeTree、個人の予定管理はGoogleカレンダー、という使い分けをしている方にぜひ試していただきたいツールです。

iPhone 標準のカレンダーと同期したい方へ:TimeTree のアプリには、予定を iPhone のカレンダーへ書き出す機能が見つかりませんでした。iPhone の予定を TimeTree に出す方法と、このツールで Google カレンダーに移してから iPhone に表示する方法は、実機で確かめたこちらの記事で解説しています。

あわせて読みたい
TimeTreeとiPhoneのカレンダーは同期できる?|できる方向・できない方向を実機で確認
TimeTreeとiPhoneのカレンダーは同期できる?|できる方向・できない方向を実機で確認

本ツールの特徴

できること
  • TimeTree Web版から指定した月範囲のイベントを自動取得
  • Googleカレンダーへ一括登録
  • イベントのタイトル、日時、場所、メモ、通知をそのまま転送
  • 二重登録を自動で防止(同じイベントは再登録されない)

動作確認環境

項目内容
ブラウザGoogle Chrome(PC版)
TimeTreeWeb版(https://timetreeapp.com/)
Googleアカウントスプレッドシートと同じアカウント

本ツールは2025年1月時点で動作確認済みです。TimeTreeのUI変更により動作しなくなる可能性があります。

対応項目

エクスポートできる項目

項目対応備考
終日イベント○
時刻指定イベント○
イベントタイトル○
メモ(説明文)○
場所○Googleカレンダーの場所欄に転記
チェックリスト○説明文にテキストとして列挙
通知(リマインダー)○ポップアップリマインダーに変換
リンク○説明文に含める
二重登録防止○TimeTree IDで判定

エクスポートできない項目

項目備考
繰り返し予定個別イベントとして取得される
招待・参加者抽出しない
添付ファイル抽出しない

通知の変換ルール

TimeTreeの通知設定は、Googleカレンダーのポップアップリマインダーに変換されます。

TimeTreeGoogleカレンダー
5分前5分前
10分前10分前
30分前30分前
1時間前60分前
1日前1440分前
前日1440分前
当日0分(イベント開始時)
1週間前10080分前

導入手順

スプレッドシートをコピー
  1. 以下のボタンのスプレッドシートを開きます
  2. 上部メニュー「ファイル」→「コピーを作成」をクリックします
  3. 自分のGoogleドライブにコピーが作成されます
SANANE
SANANE

コピーしたスプレッドシートにはGASのコードがすでに含まれているため、コードを貼り付ける作業は不要です。

GASをデプロイ
  1. コピーしたスプレッドシートを開きます
  2. 上部メニュー「拡張機能」→「Apps Script」をクリックします
  3. 右上の「デプロイ」→「新しいデプロイ」をクリックします
  4. 「種類の選択」で歯車アイコンをクリックし「ウェブアプリ」を選択します
  5. 以下の設定を行います:
    ・実行ユーザー: 「自分」
    ・アクセスできるユーザー: 「全員」
  6. 「デプロイ」をクリックします
GASの初回認証
  1. 初回デプロイ時に認証画面が表示されます
  2. 「アクセスを承認」をクリックします
  3. 自分のGoogleアカウントを選択します
  4. 「このアプリは確認されていません」と表示された場合は「Advanced」をクリックします
  5. 一番下の「Goto(プロジェクト名)」をクリックします
  6. 全てにチェックを入れて「Continue」をクリックします
  7. 表示された「ウェブアプリのURL」をコピーして保存します

このURLは後で使用するので、必ずメモしておいてください。

SANANE
SANANE

この認証を行うことで、GASがあなたのGoogleカレンダーにイベントを作成できるようになります。

ブックマークレットを登録
  1. ブラウザのブックマークバーを表示します
    ・Windows: Ctrl + Shift + B
    ・Mac: Cmd + Shift + B
  2. ブックマークバーの空いている場所を右クリックします
  3. 「ページを追加」または「ブックマークを追加」をクリックします
  4. 以下のように入力します:
    ・名前: TimeTree Export(任意)
    ・URL: 下記のコードを貼り付け
  5. 「保存」をクリックします

ブックマークレットのコードは記事下部に掲載しています。

TimeTreeでエクスポートを実行
  1. TimeTree Web版(https://timetreeapp.com/)にログインします
  2. エクスポートしたいカレンダーを表示させます。(表示されている月が移行対象となります)
  3. ブックマークバーに登録した「TimeTree Export」をクリックします
  4. 設定モーダルが表示されます:
    ・GAS URL: STEP2でコピーしたURLを貼り付け
    ・カレンダーID: 空欄のままでOK(メインカレンダーに登録されます)
  5. 「実行」をクリックします

ブラウザの設定によってはポップアップがブロックされるため、許可してから再度実行してください。

結果を確認
  1. 処理完了後、新しいタブでGASの結果が表示されます
  2. Googleカレンダーを開いて、イベントが登録されていることを確認します
  3. スプレッドシートの「log」シートに処理履歴が記録されます
SANANE
SANANE

2回目以降の実行では、GAS URLが自動で入力されます。設定はブラウザに保存されるため、毎回入力する必要はありません。

ブックマークレットのコード

以下のコードをブックマークのURL欄に貼り付けてください。

javascript:(() => {  const waitFor = (selector, timeout = 5000) =>    new Promise((resolve, reject) => {      const found = document.querySelector(selector);      if (found) return resolve(found);      const observer = new MutationObserver(() => {        const el = document.querySelector(selector);        if (el) {          observer.disconnect();          resolve(el);        }      });      observer.observe(document.body, { childList: true, subtree: true });      setTimeout(() => {        observer.disconnect();        reject(new Error(`Timeout: ${selector} not found`));      }, timeout);    });  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));  const getEventsForCurrentMonth = () => {    const eventButtons = [      ...document.querySelectorAll('div[style*="--lndlxo2"] > button'),    ];    return eventButtons.map((button, index) => {      const container = button.parentElement;      const spans = [...button.querySelectorAll('span')];      const title = (spans[0]?.textContent || button.textContent || '').trim();      const timeText =        spans          .slice(1)          .map((span) => span.textContent.trim())          .filter(Boolean)          .join(' ') || null;      return {        index,        title,        time: timeText,        row: container?.style.getPropertyValue('--lndlxo2')?.trim() || null,        day: container?.style.getPropertyValue('--lndlxo3')?.trim() || null,        spanDays: container?.style.getPropertyValue('--lndlxo4')?.trim() || null,        button,      };    });  };  const extractDateTime = (detailRoot) => {    const startBlock = detailRoot.querySelector(      '[data-test-id="event-date-time-start"]',    );    const endBlock = detailRoot.querySelector(      '[data-test-id="event-date-time-end"]',    );    const normalize = (text) => text.replace(/\s+/g, ' ').trim();    const readBlock = (block) => {      if (!block) return { raw: '', timeText: null };      const raw = normalize(block.textContent || '');      const timeEl = block.querySelector('._1nud6zk1');      const timeText = timeEl ? normalize(timeEl.textContent || '') : null;      return { raw, timeText };    };    const parseDateString = (text) => {      if (!text) return null;      const t = text.replace(/[()]/g, '');      let m =        t.match(/(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})/) ||        t.match(/(\d{4})[\/.\-](\d{1,2})[\/.\-](\d{1,2})/);      if (m) return { year: +m[1], month: +m[2], day: +m[3] };      m = t.match(/(\d{1,2})\s*月\s*(\d{1,2})\s*日/);      if (m) {        const year = new Date().getFullYear();        return { year, month: +m[1], day: +m[2] };      }      return null;    };    const parseTimeString = (text) => {      if (!text) return null;      const meridian = text.includes('午後')        ? 'PM'        : text.includes('午前')        ? 'AM'        : null;      const m = text.match(/(\d{1,2}):(\d{2})/);      if (!m) return null;      let hour = +m[1];      const minute = +m[2];      if (meridian === 'PM' && hour < 12) hour += 12;      if (meridian === 'AM' && hour === 12) hour = 0;      return { hour, minute };    };    const startInfo = readBlock(startBlock);    const endInfo = readBlock(endBlock);    if (!startBlock) {      const h2 = detailRoot.querySelector('h2');      const dateText = h2 ? normalize(h2.textContent || '') : '';      const chunks = dateText.split(/[〜~→]/).map((s) => s.trim()).filter(Boolean);      const startParts = parseDateString(chunks[0] || '');      const endParts = chunks.length > 1 ? parseDateString(chunks[1]) : startParts;      const startDate = startParts        ? new Date(startParts.year, startParts.month - 1, startParts.day)        : null;      const endDate = endParts        ? new Date(endParts.year, endParts.month - 1, endParts.day)        : startDate;      return {        startText: dateText,        endText: dateText,        startDate,        endDate,        allDay: true,      };    }    const startDateParts = parseDateString(startInfo.raw);    const endDateParts = parseDateString(endInfo.raw) || startDateParts;    const startTimeParts =      parseTimeString(startInfo.timeText) || parseTimeString(startInfo.raw);    const endTimeParts =      parseTimeString(endInfo.timeText) || parseTimeString(endInfo.raw);    const startDate = startDateParts      ? new Date(          startDateParts.year,          startDateParts.month - 1,          startDateParts.day,          startTimeParts ? startTimeParts.hour : 0,          startTimeParts ? startTimeParts.minute : 0,        )      : null;    const endDate = endDateParts      ? new Date(          endDateParts.year,          endDateParts.month - 1,          endDateParts.day,          endTimeParts ? endTimeParts.hour : 0,          endTimeParts ? endTimeParts.minute : 0,        )      : startDate;    const allDay = !startTimeParts && !endTimeParts;    return {      startText: startInfo.raw,      endText: endInfo.raw,      startDate,      endDate,      allDay,    };  };  const extractMemo = (detailRoot) => {    const memoSelectors = [      '[data-test-id="event-note"]',      '[data-test-id="event-notes"]',      '[data-test-id="event-detail-note"]',      '[data-test-id="event-description"]',      '[data-test-id="event-detail-description"]',      '[data-test-id="event-text"]',      '[data-test-id="event-memo"]',      '[data-test-id="event-detail-text"]',      '.exlc7u1',    ];    for (const sel of memoSelectors) {      const el = detailRoot.querySelector(sel);      if (el) {        const text = el.textContent.trim();        if (text) return text;      }    }    return '';  };  const extractLocation = (detailRoot) => {    const link = detailRoot.querySelector(      'a[href*="maps.google.com"], a[href*="google.com/maps"]',    );    if (!link) return { name: '', url: '' };    const textEl = link.querySelector('span') || link;    return {      name: (textEl.textContent || '').trim(),      url: link.getAttribute('href') || '',    };  };  const extractLinks = (detailRoot, locationUrl) => {    const anchors = [      ...detailRoot.querySelectorAll('a[href^="http"]'),    ].map((a) => a.getAttribute('href'));    const filtered = anchors.filter(      (href) =>        href &&        !href.includes('/events/') &&        !href.includes('maps.google.com') &&        !href.includes('google.com/maps') &&        href !== locationUrl,    );    return [...new Set(filtered)];  };  const extractChecklist = (detailRoot) => {    const items = [      ...detailRoot.querySelectorAll(        '[data-test-id="checklist-row-item"] [data-test-id="list-item-title"]',      ),    ];    return items.map((el) => el.textContent.trim()).filter(Boolean);  };  const extractNotifications = (detailRoot) => {    const NOTIF_PATH = 'M17.392864,2 ';    const notifPattern = /(\d+)\s*(分|時間|日|週|週間)前|^(当日|前日|翌日)$/;    const results = [];    for (const svg of detailRoot.querySelectorAll('svg')) {      if (svg.closest('a')) continue;      const paths = [...svg.querySelectorAll('path')];      if (!paths.some((p) => (p.getAttribute('d') || '').startsWith(NOTIF_PATH))) continue;      const textEl = svg.nextElementSibling;      const text = textEl ? textEl.textContent.trim() : '';      if (text && notifPattern.test(text)) results.push(text);    }    return [...new Set(results)];  };  const extractLabels = (detailRoot) => {    const TAG_PATH = 'M5.9310029,2.18879633';    const labels = [];    for (const svg of detailRoot.querySelectorAll('svg')) {      if (svg.closest('a')) continue;      const paths = [...svg.querySelectorAll('path')];      if (!paths.some((p) => (p.getAttribute('d') || '').startsWith(TAG_PATH))) continue;      const textEl = svg.nextElementSibling;      const text = textEl ? textEl.textContent.trim() : '';      if (text) labels.push(text);    }    return [...new Set(labels)];  };  const closeDetail = (detailRoot) => {    const closeBtn = detailRoot.querySelector('button[aria-label="閉じる"]');    if (closeBtn) closeBtn.click();  };  const pad = (n) => String(n).padStart(2, '0');  const toDateStr = (d) =>    `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;  const toLocalIso = (d) =>    `${toDateStr(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;  const extractTimeTreeId = (detailRoot) => {    const hrefs = [      ...detailRoot.querySelectorAll('a[href*="/events/"]'),      ...detailRoot.querySelectorAll('link[href*="/events/"]'),    ]      .map((a) => a.getAttribute('href'))      .filter(Boolean);    hrefs.push(window.location.href);    for (const href of hrefs) {      const m = href.match(/events\/([a-zA-Z0-9_-]+)/);      if (m) return m[1];    }    return null;  };  const parseMonthInput = (value) => {    if (!value) return null;    const m = value.match(/^(\d{4})-(\d{2})$/);    if (!m) return null;    return { year: +m[1], month: +m[2] };  };  const toMonthLabel = ({ year, month }) => `${year}年${month}月`;  const getDisplayedMonth = () => {    const timeEl = document.querySelector(      '[data-test-id="calendar-pagination"] time',    );    if (!timeEl) return null;    const datetime = timeEl.getAttribute('datetime') || '';    return parseMonthInput(datetime);  };  const createConfigModal = (defaults) =>    new Promise((resolve) => {      const overlay = document.createElement('div');      overlay.style.cssText = [        'position:fixed',        'inset:0',        'background:rgba(0,0,0,0.55)',        'z-index:2147483647',        'display:flex',        'align-items:center',        'justify-content:center',        'pointer-events:auto',      ].join(';');      const modal = document.createElement('div');      modal.style.cssText = [        'background:#fff',        'color:#111',        'width:min(520px, 92vw)',        'padding:20px',        'border-radius:10px',        'box-shadow:0 10px 30px rgba(0,0,0,0.35)',        'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',      ].join(';');      const title = document.createElement('div');      title.textContent = 'エクスポート設定';      title.style.cssText = 'font-size:16px;font-weight:600;margin-bottom:10px';      const help = document.createElement('div');      help.textContent =        'GAS の URL を入力してください。現在表示中の月を対象にします。';      help.style.cssText = 'font-size:12px;color:#444;margin-bottom:12px';      const urlLabel = document.createElement('div');      urlLabel.textContent = 'GAS エンドポイント URL';      urlLabel.style.cssText = 'font-size:12px;color:#333;margin-bottom:6px';      const urlInput = document.createElement('input');      urlInput.type = 'url';      urlInput.placeholder = 'https://script.google.com/macros/s/.../exec';      urlInput.value = defaults?.url || '';      urlInput.style.cssText = [        'width:100%',        'box-sizing:border-box',        'padding:10px',        'border:1px solid #ccc',        'border-radius:6px',        'font-size:13px',      ].join(';');      const calendarLabel = document.createElement('div');      calendarLabel.textContent = 'GoogleカレンダーID(任意)';      calendarLabel.style.cssText = 'font-size:12px;color:#333;margin:14px 0 6px';      const calendarInput = document.createElement('input');      calendarInput.type = 'text';      calendarInput.placeholder = 'primary  または xxxx@group.calendar.google.com';      calendarInput.value = defaults?.calendarId || '';      calendarInput.style.cssText = [        'width:100%',        'box-sizing:border-box',        'padding:10px',        'border:1px solid #ccc',        'border-radius:6px',        'font-size:13px',      ].join(';');      const error = document.createElement('div');      error.style.cssText =        'font-size:12px;color:#b00020;margin-top:8px;min-height:16px';      const buttons = document.createElement('div');      buttons.style.cssText =        'display:flex;justify-content:flex-end;gap:8px;margin-top:16px';      const cancelBtn = document.createElement('button');      cancelBtn.textContent = 'キャンセル';      cancelBtn.style.cssText =        'padding:8px 12px;border:1px solid #bbb;border-radius:6px;background:#f5f5f5';      const okBtn = document.createElement('button');      okBtn.textContent = '実行';      okBtn.style.cssText =        'padding:8px 12px;border:1px solid #1b6ef3;border-radius:6px;background:#1b6ef3;color:#fff';      buttons.appendChild(cancelBtn);      buttons.appendChild(okBtn);      modal.appendChild(title);      modal.appendChild(help);      modal.appendChild(urlLabel);      modal.appendChild(urlInput);      modal.appendChild(calendarLabel);      modal.appendChild(calendarInput);      modal.appendChild(error);      modal.appendChild(buttons);      overlay.appendChild(modal);      document.body.appendChild(overlay);      const cleanup = (value) => {        document.removeEventListener('keydown', onKey);        overlay.remove();        resolve(value);      };      const validate = () => {        const urlValue = urlInput.value.trim();        if (!urlValue) {          error.textContent = 'URL を入力してください。';          return null;        }        error.textContent = '';        return {          urlValue,          calendarId: calendarInput.value.trim(),        };      };      const onSubmit = () => {        const value = validate();        if (value) {          try {            localStorage.setItem('timetreeGasEndpoint', value.urlValue);            localStorage.setItem(              'timetreeCalendarId',              value.calendarId || '',            );          } catch {          }          cleanup({            gasUrl: value.urlValue,            calendarId: value.calendarId,          });        }      };      const onCancel = () => cleanup(null);      const onKey = (event) => {        if (event.key === 'Escape') onCancel();        if (event.key === 'Enter') onSubmit();      };      cancelBtn.addEventListener('click', onCancel);      okBtn.addEventListener('click', onSubmit);      urlInput.addEventListener('input', () => {        if (error.textContent) validate();      });      calendarInput.addEventListener('input', () => {        if (error.textContent) validate();      });      document.addEventListener('keydown', onKey);      urlInput.focus();      urlInput.select();    });  const createProcessingOverlay = () => {    const overlay = document.createElement('div');    overlay.style.cssText = [      'position:fixed',      'inset:0',      'background:rgba(12,12,12,0.6)',      'z-index:2147483647',      'display:flex',      'align-items:center',      'justify-content:center',      'pointer-events:auto',    ].join(';');    const box = document.createElement('div');    box.style.cssText = [      'background:#fff',      'color:#111',      'padding:18px 20px',      'border-radius:10px',      'width:min(520px, 92vw)',      'font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',      'box-shadow:0 10px 30px rgba(0,0,0,0.35)',    ].join(';');    const title = document.createElement('div');    title.textContent = '処理中...';    title.style.cssText = 'font-size:16px;font-weight:600;margin-bottom:8px';    const log = document.createElement('div');    log.style.cssText = [      'font-size:12px',      'color:#333',      'line-height:1.6',      'max-height:200px',      'overflow-y:auto',      'border:1px solid #e0e0e0',      'border-radius:4px',      'padding:6px 8px',      'background:#fafafa',    ].join(';');    box.appendChild(title);    box.appendChild(log);    overlay.appendChild(box);    document.body.appendChild(overlay);    return {      setStatus: (text) => {        const line = document.createElement('div');        line.textContent = text;        log.appendChild(line);        log.scrollTop = log.scrollHeight;      },      remove: () => {        overlay.remove();      },    };  };  (async () => {    const storedUrl = (() => {      try {        return localStorage.getItem('timetreeGasEndpoint');      } catch {        return null;      }    })();    const storedCalendarId = (() => {      try {        return localStorage.getItem('timetreeCalendarId');      } catch {        return null;      }    })();    const displayedMonth = (() => {      const current = getDisplayedMonth();      if (current) return current;      const now = new Date();      return { year: now.getFullYear(), month: now.getMonth() + 1 };    })();    const config = await createConfigModal({      url: storedUrl || '',      calendarId: storedCalendarId || '',    });    if (!config) {      console.warn('TimeTree bookmarklet: canceled by user.');      return;    }    const processing = createProcessingOverlay();    const results = [];    const skipped = [];    const seenIds = new Set();    const seenFallback = new Set();    const allEvents = [];    try {      const month = displayedMonth;      processing.setStatus(`イベント取得中: ${toMonthLabel(month)}`);      await sleep(200);      const monthEvents = getEventsForCurrentMonth();      allEvents.push(...monthEvents);      if (!monthEvents.length) {        processing.setStatus(`イベントなし: ${toMonthLabel(month)}`);        await sleep(200);      } else {        for (const [index, ev] of monthEvents.entries()) {          try {            processing.setStatus(              `イベント取得中 (${toMonthLabel(month)} ${                index + 1              }/${monthEvents.length}): ${ev.title || 'イベント'}`,            );            ev.button.scrollIntoView({ behavior: 'smooth', block: 'center' });            ev.button.click();            const detailRoot = await waitFor('[data-test-id="event-detail"]');            const titleEl = detailRoot.querySelector(              '[data-test-id="event-title"]',            );            const title = titleEl              ? titleEl.textContent.trim()              : ev.title || 'イベント';            const timetreeId = extractTimeTreeId(detailRoot);            const dateInfo = extractDateTime(detailRoot);            const memo = extractMemo(detailRoot);            const location = extractLocation(detailRoot);            const links = extractLinks(detailRoot, location.url);            const checklist = extractChecklist(detailRoot);            const notifications = extractNotifications(detailRoot);            const labels = extractLabels(detailRoot);            let isDuplicate = false;            if (timetreeId) {              if (seenIds.has(timetreeId)) {                isDuplicate = true;              } else {                seenIds.add(timetreeId);              }            } else {              const fallbackKey = `${title}|${dateInfo.startText}|${dateInfo.endText}|${dateInfo.allDay}`;              if (seenFallback.has(fallbackKey)) {                isDuplicate = true;              } else {                seenFallback.add(fallbackKey);              }            }            if (isDuplicate) {              skipped.push({                title,                reason: 'duplicate across months',                error: '',              });              continue;            }            results.push({              title,              timetreeId,              allDay: dateInfo.allDay,              startDate: dateInfo.startDate,              endDate: dateInfo.endDate,              startText: dateInfo.startText,              endText: dateInfo.endText,              memo,              locationName: location.name,              locationUrl: location.url,              links,              checklist,              notifications,              labels,              day: ev.day,              gridRow: ev.row,              spanDays: ev.spanDays,            });          } catch (err) {            skipped.push({              title: ev.title,              reason: 'detail not accessible (non-clickable or already fixed event)',              error: err.message || String(err),            });            processing.setStatus(              `スキップ (${index + 1}/${monthEvents.length}): ${ev.title || 'イベント'}`,            );            console.warn('Skipped event (detail not accessible):', ev.title, err);          } finally {            const opened = document.querySelector(              '[data-test-id="event-detail"]',            );            if (opened) closeDetail(opened);            await sleep(250);          }        }      }      processing.setStatus('イベントを送信しています...');      console.table(        results.map(          ({            title,            startDate,            endDate,            allDay,            memo,            day,            spanDays,            gridRow,            timetreeId,          }) => ({            title,            timetreeId,            start: allDay              ? startDate                ? toDateStr(startDate)                : '終日'              : startDate              ? toLocalIso(startDate)              : '',            end: allDay              ? endDate                ? toDateStr(endDate)                : '終日'              : endDate              ? toLocalIso(endDate)              : '',            allDay,            memo,            day,            spanDays,            gridRow,          }),        ),      );      if (skipped.length) {        console.table(          skipped.map(({ title, reason, error }) => ({ title, reason, error })),        );      }      const gasEvents = results.map((r) =>        r.allDay          ? {              title: r.title,              timetreeId: r.timetreeId,              allDay: true,              startDate: r.startDate ? toDateStr(r.startDate) : r.startText,            endDate: r.endDate ? toDateStr(r.endDate) : r.endText,            description: r.memo,            location: r.locationName,            locationUrl: r.locationUrl,            links: r.links,            checklist: r.checklist,            notifications: r.notifications,            labels: r.labels,          }        : {            title: r.title,            timetreeId: r.timetreeId,            allDay: false,            startDateTime: r.startDate ? toLocalIso(r.startDate) : r.startText,            endDateTime: r.endDate ? toLocalIso(r.endDate) : r.endText,            description: r.memo,            location: r.locationName,            locationUrl: r.locationUrl,            links: r.links,            checklist: r.checklist,            notifications: r.notifications,            labels: r.labels,          },    );      window._timetreeEvents = allEvents;      window._timetreeEventsDetailed = results;      window._timetreeGasEvents = gasEvents;      console.log(        'Stored window._timetreeEvents, window._timetreeEventsDetailed, and window._timetreeGasEvents. Each item keeps the button for manual clicks.',      );      console.log('GAS payload (JSON):', JSON.stringify(gasEvents, null, 2));      const payload = JSON.stringify({        events: gasEvents,        calendarId: config.calendarId || '',      });      const form = document.createElement('form');      form.method = 'POST';      form.action = config.gasUrl;      form.target = '_blank';      const input = document.createElement('input');      input.type = 'hidden';      input.name = 'payload';      input.value = payload;      form.appendChild(input);      document.body.appendChild(form);      form.submit();      form.remove();      console.log('Posted to GAS endpoint via form submit (target=_blank).');      processing.setStatus('送信完了。タブで結果を確認してください。');      setTimeout(() => processing.remove(), 1000);    } catch (err) {      console.error('TimeTree bookmarklet error:', err);      processing.setStatus(`エラーが発生しました: ${err.message || err}`);      setTimeout(() => processing.remove(), 2000);    }  })();})();
SANANE
SANANE

コードが長いため、コピー時に途中で切れないよう注意してください。

注意事項

セキュリティについて

GASのURLは第三者に共有しないでください

このURLを知っている人は、誰でもあなたのGoogleカレンダーにイベントを書き込むことができてしまいます。URLが漏洩した場合は、GASを再デプロイしてURLを変更してください。

制限事項

  • イベント数が多い場合: GASの実行時間制限(6分)を超える可能性があります。月範囲を小さくして分割実行してください
  • 手動実行のみ: 定期的な自動同期はできません。都度ブックマークレットを実行してください

二重登録防止の仕組み

本ツールでは、各イベントにTimeTree IDを紐づけてGoogleカレンダーの説明文に「TimeTreeID:xxx」として記録しています。同じIDのイベントが既に存在する場合はスキップされるため、何度実行しても二重登録されません。

ただし、検索範囲は「現在〜1年後」のため、過去のイベントは重複チェックの対象外となります。

カレンダーの指定について

デフォルトではGoogleカレンダーのメインカレンダー(primary)に登録されます。別のカレンダーに登録したい場合は、設定モーダルの「カレンダーID」欄にカレンダーIDを入力してください。

カレンダーIDは、Googleカレンダーの設定画面で確認できます(例: xxxx@group.calendar.google.com)。

あわせて読みたい
GoogleカレンダーのIDを探す方法を紹介!GASなどアプリケーションの連携に利用可能に
GoogleカレンダーのIDを探す方法を紹介!GASなどアプリケーションの連携に利用可能に

免責事項

  • 本ツールは個人利用を想定しています
  • TimeTreeおよびGoogleカレンダーの仕様変更により、予告なく動作しなくなる可能性があります
  • 本ツールの使用によって生じたいかなる損害についても、作者は責任を負いません
  • イベントデータはGASを経由してGoogleカレンダーに送信されます

Google Apps Script を書籍で学びたい方へ

この記事のようなGoogle Apps Scriptのコードをもっと自分で書けるようになりたい方には、以下の書籍がおすすめです。

詳解! Google Apps Script完全入門 第3版

詳解! Google Apps Script完全入門 第3版

GAS学習の定番書。文法から外部連携まで体系的にマスターしたい方に。

できるイラストで学ぶ 入社1年目からのGoogle Apps Script

できるイラストで学ぶ 入社1年目からのGoogle Apps Script

イラストで学べる入門書。プログラミングが初めての方でも手を動かしながら学べます。

まとめ

本記事では、TimeTreeの予定をGoogleカレンダーへ一括エクスポートする方法を紹介しました。

  • ブックマークレット + GASで簡単にエクスポート可能
  • タイトル、日時、場所、メモ、通知をそのまま転送
  • 二重登録防止機能付きで安心

TimeTreeとGoogleカレンダーを併用している方は、ぜひ試してみてください。

カスタマイズの依頼について

「本記事の紹介内容をもっと自分用にカスタマイズしたい」(複数月の範囲実行など)

「設定がうまくいかず依頼したい」

「別のツールの作成依頼をしたい」

上記ご要望について、ココナラというサービスにて承っております。

相談は無料ですのでぜひお気軽にご相談ください。

SANANE
SANANE

[見積り相談をする]からご相談ください!

記事URLをコピーしました