日程画面→Googleカレンダーに登録を一瞬で行うフロー[ショートカット]

# 完成品

https://x.com/mulQVUdHK523735/status/1860669129092669463

## 経緯
以下のポスト(ツイート)を発見。あまりにスマートすぎて再現しました。ってだけだとただのパクリなので、ひと工夫加えて、その場でどんな予定が追加されたか見えるようにしました。

https://x.com/tsujichan2424/status/1860124006952894632

https://x.com/moutaberenai98/status/1846232607593582962

:::note info
2024-12-7: 下にOpenAI APIのバージョンも載せておきます。
:::


## 1. Google Apps Scriptで作成
https://script.new からGASを新規作成して以下をペーストしてください。
```js
function doPost(e) {
  try {
    // 受信したスクリーンショット文字起こしテキスト
    const requestBody = JSON.parse(e.postData.contents);
    const rawText = requestBody.text;

    // 入力データ検証
    if (!rawText || typeof rawText !== "string") {
      console.log("Invalid input: 'text' field is required and must be a string.");
      throw new Error("Invalid input: 'text' field is required and must be a string.");
    }

    // Gemini APIを呼び出して構造化データを取得
    const structuredData = fetchGeminiStructuredOutput(rawText);

    // データ検証
    if (!structuredData || !structuredData.events || !Array.isArray(structuredData.events)) {
      console.log("Unexpected response format from Gemini API", structuredData);
      return createErrorResponse("Unexpected response format from Gemini API", structuredData);
    }

    // Googleカレンダーに予定を登録
    const errors = [];
    const icsEvents = [];
    structuredData.events.forEach(event => {
      try {
        validateEvent(event); // イベントデータの検証
        addEventToCalendar(event); // イベント追加
        icsEvents.push(convertEventToICS(event)); // iCS形式に変換
      } catch (error) {
        console.log({ event, message: error.message });
        errors.push({ event, message: error.message }); // エラーがあった場合
      }
    });

    // エラーがあった場合は詳細をレスポンスに含める
    if (errors.length > 0) {
      console.log("Some events failed to be added to the calendar", errors);
      return createErrorResponse("Some events failed to be added to the calendar", errors);
    }

    // ICSファイルの雛形を追加
    const icsContent = [
      'BEGIN:VCALENDAR',
      'VERSION:2.0',
      'PRODID:-//hacksw/handcal//NONSGML v1.0//EN',
      ...icsEvents,
      'END:VCALENDAR'
    ].join('\n');

    return ContentService
      .createTextOutput(icsContent)
      .setMimeType(ContentService.MimeType.PLAIN_TEXT);
      
  } catch (error) {
    console.log(error.message);
    return createErrorResponse("Error in doPost", error.message);
  }
}

// イベントをiCS形式に変換
function convertEventToICS(event) {
  let dtStart, dtEnd;
  if (event.start.date && event.end.date) {
    // 終日の予定の場合
    dtStart = `VALUE=DATE:${event.start.date.replace(/-/g, "")}`;
    dtEnd = `VALUE=DATE:${event.end.date.replace(/-/g, "")}`;
  } else {
    // 時間指定の予定の場合
    dtStart = `${formatDateToICS(event.start.dateTime)}`;
    dtEnd = `${formatDateToICS(event.end.dateTime)}`;
  }
  return `BEGIN:VEVENT
SUMMARY:${event.title}
DTSTART:${dtStart}
DTEND:${dtEnd}
LOCATION:${event.location || ""}
DESCRIPTION:${event.description || ""}
END:VEVENT`;
}

// 日付をiCS形式にフォーマット
function formatDateToICS(dateTime) {
  const d = new Date(dateTime);
  return d.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
}

// Gemini APIを呼び出してJSON形式の構造化データを取得
function fetchGeminiStructuredOutput(text) {
  const API_KEY = PropertiesService.getScriptProperties().getProperty("GEMINI_API_KEY");
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${API_KEY}`;

  const nowTimestamp = new Date().toISOString();


  const payload = {
    contents: [
      {
        parts: [
          {
            text: `以下のテキストから適切なツールを選択し、指定されたJSON形式で返してください。

- 終日の予定の場合: startとendには "date" フィールド（"yyyy-mm-dd"形式）を使用してください。
- 時間指定の予定の場合: startとendには "dateTime" フィールド（RFC3339形式）を使用してください。
- 終日の予定ではないかつ終了時間が設定されていない予定に関しては1時間をデフォルトで選択してください。
- descriptionはHTMLを含めることができます。
参考 > 現在時刻:${nowTimestamp}

JSONフォーマット:
{
  "tool": "string",
  "events": [
    {
      "title": "string",
      "start": {
        "date": "string",
        "dateTime": "string"
      },
      "end": {
        "date": "string",
        "dateTime": "string"
      },
      "location": "string",
      "description": "string"
    }
  ]
}

テキスト: "${text}"`
          }
        ]
      }
    ],
    generationConfig: {
      response_mime_type: "application/json",
      response_schema: {
        type: "OBJECT",
        properties: {
          tool: { type: "STRING" },
          events: {
            type: "ARRAY",
            items: {
              type: "OBJECT",
              properties: {
                title: { type: "STRING" },
                start: {
                  type: "OBJECT",
                  properties: {
                    date: { type: "STRING" },
                    dateTime: { type: "STRING" }
                  },
                  anyOf: [
                    { required: ["date"] },
                    { required: ["dateTime"] }
                  ]
                },
                end: {
                  type: "OBJECT",
                  properties: {
                    date: { type: "STRING" },
                    dateTime: { type: "STRING" }
                  },
                  anyOf: [
                    { required: ["date"] },
                    { required: ["dateTime"] }
                  ]
                },
                location: { type: "STRING" },
                description: { type: "STRING" }
              },
              required: ["title", "start", "end"]
            }
          }
        },
        required: ["tool", "events"] // eventsを必須項目に設定
      }
    }
  };

  const options = {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(payload)
  };

  try {
    const response = UrlFetchApp.fetch(url, options);
    const responseData = JSON.parse(response.getContentText());

    // 必要なデータを抽出
    if (responseData.candidates && responseData.candidates[0].content.parts[0].text) {
      const structuredDataText = responseData.candidates[0].content.parts[0].text;
      console.log("Gemini API Response:", structuredDataText);
      return JSON.parse(structuredDataText);
    } else {
      console.log("Malformed response from Gemini API.");
      throw new Error("Malformed response from Gemini API.");
    }
  } catch (error) {
    console.log(`Failed to fetch structured data from Gemini API: ${error.message}`);
    throw new Error(`Failed to fetch structured data from Gemini API: ${error.message}`);
  }
}

// Googleカレンダーにイベントを追加
function addEventToCalendar(event) {
  const calendar = CalendarApp.getDefaultCalendar();
  if (event.start.date && event.end.date) {
    // 終日の予定を作成
    calendar.createAllDayEvent(
      event.title,
      new Date(event.start.date),
      {
        location: event.location || "",
        description: event.description || ""
      }
    );
  } else if (event.start.dateTime && event.end.dateTime) {
    // 時間指定の予定を作成
    calendar.createEvent(
      event.title,
      new Date(event.start.dateTime),
      new Date(event.end.dateTime),
      {
        location: event.location || "",
        description: event.description || ""
      }
    );
  } else {
    throw new Error("Event start and end times are missing or invalid.");
  }
}

// イベントデータの検証
function validateEvent(event) {
  if (!event.title || typeof event.title !== "string") {
    console.log("Event title is missing or invalid.");
    throw new Error("Event title is missing or invalid.");
  }
  if (event.start.date && event.end.date) {
    // 終日の予定の場合
    if (isNaN(new Date(event.start.date).getTime())) {
      console.log("Event start date is invalid.");
      throw new Error("Event start date is invalid.");
    }
    if (isNaN(new Date(event.end.date).getTime())) {
      console.log("Event end date is invalid.");
      throw new Error("Event end date is invalid.");
    }
    if (new Date(event.start.date) > new Date(event.end.date)) {
      console.log("Event start date must be before end date.");
      throw new Error("Event start date must be before end date.");
    }
  } else if (event.start.dateTime && event.end.dateTime) {
    // 時間指定の予定の場合
    if (isNaN(new Date(event.start.dateTime).getTime())) {
      console.log("Event start time is invalid.");
      throw new Error("Event start time is invalid.");
    }
    if (isNaN(new Date(event.end.dateTime).getTime())) {
      console.log("Event end time is invalid.");
      throw new Error("Event end time is invalid.");
    }
    if (new Date(event.start.dateTime) >= new Date(event.end.dateTime)) {
      console.log("Event start time must be before end time.");
      throw new Error("Event start time must be before end time.");
    }
  } else {
    console.log("Event start and end times are missing.");
    throw new Error("Event start and end times are missing.");
  }
}

// エラーレスポンス作成
function createErrorResponse(message, details) {
  const errorResponse = {
    status: "error",
    message: message,
    details: details
  };
  return ContentService.createTextOutput(JSON.stringify(errorResponse))
    .setMimeType(ContentService.MimeType.JSON);
}
```
:::note info
拡張性を確保するためにJSONスキーマに`tool`を入れていますが、今のところは使用していません。今後、さまざまなGoogleサービスに対応させていきたいです。
:::

### Gemini APIを設定

プロジェクトの設定 > スクリプトプロパティにGeminiのAPIを貼り付けます。
> プロパティ名：GEMINI_API_KEY


:::note info
https://aistudio.google.com/app/apikey からAPIキーを取得します。

:::


:::note warn
無料のGemini APIは現在のところ、Googleによって学習に利用される場合があります。

:::


ウェブアプリとしてデプロイし、URLが表示されます。あとで使います。

## 3. iOSショートカットを追加

https://www.icloud.com/shortcuts/e67e61ed4f97472ea5576f8d68ac91c2

※初めての場合、いくらか設定が必要になると思われますが、ここでは割愛します。

## 4. 背面タップ(orアクションボタン)に設定

1. ホーム画面からSpotlight検索を出して「背面タップ」と検索
1. そこから「スクショ→カレンダー」を探して設定

## 5. おわり

たくさんイベントや公演に参加しましょう！！！
※Copilotフル活用して作ったので素の私は何もできません。ありがとうCopilot。

## 6. OpenAI API

```javascript
function doPost(e) {
  try {
    // 受信したスクリーンショット文字起こしテキスト
    const requestBody = JSON.parse(e.postData.contents);
    const rawText = requestBody.text;

    // 入力データ検証
    if (!rawText || typeof rawText !== "string") {
      console.log("Invalid input: 'text' field is required and must be a string.");
      throw new Error("Invalid input: 'text' field is required and must be a string.");
    }

    // OpenAI APIを呼び出して構造化データを取得
    const structuredData = fetchOpenAIStructuredOutput(rawText);

    // データ検証
    if (!structuredData || !structuredData.events || !Array.isArray(structuredData.events)) {
      console.log("Unexpected response format from OpenAI API", structuredData);
      return createErrorResponse("Unexpected response format from OpenAI API", structuredData);
    }

    // Googleカレンダーに予定を登録
    const errors = [];
    const icsEvents = [];
    structuredData.events.forEach(event => {
      try {
        validateEvent(event); // イベントデータの検証
        addEventToCalendar(event); // イベント追加
        icsEvents.push(convertEventToICS(event)); // ICS形式に変換
      } catch (error) {
        console.log({ event, message: error.message });
        errors.push({ event, message: error.message }); // エラーがあった場合
      }
    });

    // エラーがあった場合は詳細をレスポンスに含める
    if (errors.length > 0) {
      console.log("Some events failed to be added to the calendar", errors);
      return createErrorResponse("Some events failed to be added to the calendar", errors);
    }

    // ICSファイルの雛形を追加
    const icsContent = [
      'BEGIN:VCALENDAR',
      'VERSION:2.0',
      'PRODID:-//hacksw/handcal//NONSGML v1.0//EN',
      ...icsEvents,
      'END:VCALENDAR'
    ].join('\n');

    return ContentService
      .createTextOutput(icsContent)
      .setMimeType(ContentService.MimeType.PLAIN_TEXT);

  } catch (error) {
    console.log(error.message);
    return createErrorResponse("Error in doPost", error.message);
  }
}

// OpenAI APIを呼び出してJSON形式の構造化データを取得
function fetchOpenAIStructuredOutput(text) {
  const API_KEY = PropertiesService.getScriptProperties().getProperty("OPENAI_API_KEY");
  const url = "https://api.openai.com/v1/chat/completions";

  const nowTimestamp = new Date().toISOString();

  const messages = [
    {
      role: "user",
      content: `以下のテキストから適切なツールを選択し、指定されたJSON形式で返してください。

- 終日の予定の場合: startとendには "date" フィールド（"yyyy-mm-dd"形式）を使用してください。
- 時間指定の予定の場合: startとendには "dateTime" フィールド（RFC3339形式）を使用してください。
- 終日の予定ではないかつ終了時間が設定されていない予定に関しては1時間をデフォルトで選択してください。
- descriptionはHTMLを含めることができます。
参考 > 現在時刻:${nowTimestamp}

テキスト: "${text}"`
    }
  ];

  const payload = {
    model: "gpt-4o-mini-2024-07-18",
    messages: messages,
    response_format: {
      "type": "json_schema",
      "json_schema": {
        "name": "event_schema",
        "strict": true,
        "schema": {
          "type": "object",
          "properties": {
            "tool": {
              "type": "string",
              "description": "The name of the tool used for events."
            },
            "events": {
              "type": "array",
              "description": "A list of events.",
              "items": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string",
                    "description": "The title of the event."
                  },
                  "start": {
                    "type": "object",
                    "properties": {
                      "date": {
                        "type": "string",
                        "description": "The start date of the event."
                      },
                      "dateTime": {
                        "type": "string",
                        "description": "The start date and time of the event."
                      }
                    },
                    "required": ["date", "dateTime"],
                    "additionalProperties": false
                  },
                  "end": {
                    "type": "object",
                    "properties": {
                      "date": {
                        "type": "string",
                        "description": "The end date of the event."
                      },
                      "dateTime": {
                        "type": "string",
                        "description": "The end date and time of the event."
                      }
                    },
                    "required": ["date", "dateTime"],
                    "additionalProperties": false
                  },
                  "location": {
                    "type": "string",
                    "description": "The location of the event."
                  },
                  "description": {
                    "type": "string",
                    "description": "A description of the event."
                  }
                },
                "required": ["title", "start", "end", "location", "description"],
                "additionalProperties": false
              }
            }
          },
          "required": ["tool", "events"],
          "additionalProperties": false
        }
      }
    },
    temperature: 1,
    max_tokens: 2048,
    top_p: 1,
    frequency_penalty: 0,
    presence_penalty: 0
  };

  const options = {
    method: "post",
    contentType: "application/json",
    headers: {
      "Authorization": "Bearer " + API_KEY
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };

  try {
    const response = UrlFetchApp.fetch(url, options);
    const responseData = JSON.parse(response.getContentText());

    // 必要なデータを抽出
    if (responseData.choices && responseData.choices[0].message && responseData.choices[0].message.content) {
      const structuredData = responseData.choices[0].message.content;
      console.log("OpenAI API Response:", structuredData);
      return JSON.parse(structuredData);
    } else {
      console.log("Malformed response from OpenAI API.");
      throw new Error("Malformed response from OpenAI API.");
    }
  } catch (error) {
    console.log(`Failed to fetch structured data from OpenAI API: ${error.message}`);
    throw new Error(`Failed to fetch structured data from OpenAI API: ${error.message}`);
  }
}

// 以降の関数は変更なし
// イベントをiCS形式に変換
function convertEventToICS(event) {
  let dtStart, dtEnd;
  if (event.start.date && !event.start.dateTime && event.end.date && !event.end.dateTime) {
    // 終日の予定の場合
    dtStart = `DTSTART;VALUE=DATE:${event.start.date.replace(/-/g, "")}`;
    dtEnd = `DTEND;VALUE=DATE:${event.end.date.replace(/-/g, "")}`;
  } else {
    // 時間指定の予定の場合
    dtStart = `DTSTART:${formatDateToICS(event.start.dateTime)}`;
    dtEnd = `DTEND:${formatDateToICS(event.end.dateTime)}`;
  }
  return `BEGIN:VEVENT
SUMMARY:${event.title}
${dtStart}
${dtEnd}
LOCATION:${event.location || ""}
DESCRIPTION:${event.description || ""}
END:VEVENT`;
}

// 日付をiCS形式にフォーマット
function formatDateToICS(dateTime) {
  const d = new Date(dateTime);
  return d.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
}

// Googleカレンダーにイベントを追加
function addEventToCalendar(event) {
  const calendar = CalendarApp.getDefaultCalendar();
  if (event.start.date && !event.start.dateTime && event.end.date && !event.end.dateTime) {
    // 終日の予定を作成
    calendar.createAllDayEvent(
      event.title,
      new Date(event.start.date),
      {
        location: event.location || "",
        description: event.description || ""
      }
    );
  } else if (event.start.dateTime && event.end.dateTime) {
    // 時間指定の予定を作成
    calendar.createEvent(
      event.title,
      new Date(event.start.dateTime),
      new Date(event.end.dateTime),
      {
        location: event.location || "",
        description: event.description || ""
      }
    );
  } else {
    throw new Error("Event start and end times are missing or invalid.");
  }
}

// イベントデータの検証
function validateEvent(event) {
  if (!event.title || typeof event.title !== "string") {
    console.log("Event title is missing or invalid.");
    throw new Error("Event title is missing or invalid.");
  }
  if (event.start.date && event.end.date) {
    // 終日の予定の場合
    if (isNaN(new Date(event.start.date).getTime())) {
      console.log("Event start date is invalid.");
      throw new Error("Event start date is invalid.");
    }
    if (isNaN(new Date(event.end.date).getTime())) {
      console.log("Event end date is invalid.");
      throw new Error("Event end date is invalid.");
    }
    if (new Date(event.start.date) > new Date(event.end.date)) {
      console.log("Event start date must be before end date.");
      throw new Error("Event start date must be before end date.");
    }
  } else if (event.start.dateTime && event.end.dateTime) {
    // 時間指定の予定の場合
    if (isNaN(new Date(event.start.dateTime).getTime())) {
      console.log("Event start time is invalid.");
      throw new Error("Event start time is invalid.");
    }
    if (isNaN(new Date(event.end.dateTime).getTime())) {
      console.log("Event end time is invalid.");
      throw new Error("Event end time is invalid.");
    }
    if (new Date(event.start.dateTime) >= new Date(event.end.dateTime)) {
      console.log("Event start time must be before end time.");
      throw new Error("Event start time must be before end time.");
    }
  } else {
    console.log("Event start and end times are missing.");
    throw new Error("Event start and end times are missing.");
  }
}

// エラーレスポンス作成
function createErrorResponse(message, details) {
  const errorResponse = {
    status: "error",
    message: message,
    details: details
  };
  return ContentService.createTextOutput(JSON.stringify(errorResponse))
    .setMimeType(ContentService.MimeType.JSON);
}
```