Reactで、予定を.icsファイルにしてダウンロードする方法

​​React​File API,iCalendar,Playwright,TypeScript

予定をカレンダーに取り込んでもらうには、iCalendar 形式(RFC 5545)の文字列を作り、Blob にして、拡張子 .ics のファイルとしてダウンロードさせます。文字列を作る部分は、ライブラリを入れずに書けます。

ただし、書き方には決まりがいくつかあります。日時の書き方、終日の予定の終わりの日、カンマや改行の書き方、長い行の折り返しです。ここでは、予定を入力して「カレンダーに追加(.ics)」を押すとファイルが保存されるコンポーネントを作り、保存されたファイルを iCalendar のパーサー(ical.js)で読み直して、入力どおりに戻るかを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0、ical.js 2.2.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright でボタンを押して、保存されたファイルの中身を調べました。ブラウザのタイムゾーンは Asia/Tokyo と America/New_York で試しています。Google カレンダー、Apple のカレンダー、Outlook などのアプリに実際に取り込む確認はしていません。Firefox と Safari、スマートフォンのブラウザでも試していません。

予定の入力欄(タイトル「React 勉強会」、日付 2026/11/20、開始 10:00、終了 11:30、場所、メモ)の下の「カレンダーに追加(.ics)」ボタンを押すと、保存された event.ics の中身が下の枠に出る。DTSTART:20261120T010000Z と DTEND:20261120T023000Z の行がある。続けて「終日」にチェックを入れて押すと、DTSTART;VALUE=DATE:20261120 と DTEND;VALUE=DATE:20261121 に変わる
ボタンを2回押して保存する動き。2回目は「終日」にしてから押した(カーソル、輪、回数、下の枠は GIF 用に重ねた表示)

.ics ファイルに書く行

Asia/Tokyo のブラウザで、上の画像の入力のままボタンを押して保存されたファイルが、次の内容です。1行ずつ JSON の文字列として出し、かっこの中に、その行の UTF-8 でのバイト数を書きました。JSON の文字列なので、ファイルの中の \ 1つが \\ と2つ重ねて表示されています。UID と DTSTAMP は保存のたびに変わるので、<…> に置き換えています。

text
"BEGIN:VCALENDAR" (15)
"VERSION:2.0" (11)
"PRODID:-//example.com//Event Download//JA" (41)
"CALSCALE:GREGORIAN" (18)
"BEGIN:VEVENT" (12)
"UID:<ランダムなUUID>@example.com" (37)
"DTSTAMP:<保存した時刻>" (28)
"DTSTART:20261120T010000Z" (24)
"DTEND:20261120T023000Z" (22)
"SUMMARY:React 勉強会" (23)
"LOCATION:会議室A\\, 3階" (26)
"DESCRIPTION:持ち物:ノートPC\\n資料は当日配ります" (64)
"END:VEVENT" (10)
"END:VCALENDAR" (13)
最後が \r\n で終わる: true / \r\n 以外の改行: false

全体を BEGIN:VCALENDAR と END:VCALENDAR で囲み、その中の BEGIN:VEVENT から END:VEVENT までが1つの予定です。RFC 5545 では、カレンダーの側に PRODID(ファイルを作ったソフトを示す識別子)と VERSION、予定の側に UID と DTSTAMP が必須です。

UID は予定を見分けるための値で、世界で重ならないものにします。RFC 5545 は、@ の右にドメイン名を置く書き方を勧めています。ここでは crypto.randomUUID() で作った UUID の後ろに @example.com を付けました。PRODID と UID の example.com は、実際には自分のサイトのドメインに置き換えます。randomUUID() は、安全なコンテキスト(HTTPS や localhost で開いたページなど)でしか使えません。DTSTAMP は、METHOD の行がないファイルでは、予定の情報を最後に更新した日時を表します。ここでは、ファイルを作った時刻を入れました。

行の区切りは CRLF(\r\n)にします。文字コードは UTF-8 で、RFC 5545 でも UTF-8 が既定です。

予定を .ics の文字列にする関数

文字列を作る toIcs と、保存する downloadIcs を ics.ts にまとめました。

ts
export type CalendarEvent = {
  uid: string;
  title: string;
  start: Date;
  end: Date;
  allDay: boolean;
  location: string;
  description: string;
};

const pad = (n: number) => String(n).padStart(2, '0');

// UTC の日時:20261120T010000Z
function utcDateTime(d: Date): string {
  return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
    `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
}

// 終日の予定の日付:20261120(ブラウザのタイムゾーンの日付)
function localDate(d: Date): string {
  return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
}

// 文字列の値の \ ; , と改行を、\ を付けた形にする
function escapeText(s: string): string {
  return s
    .replace(/\\/g, '\\\\')
    .replace(/;/g, '\\;')
    .replace(/,/g, '\\,')
    .replace(/\r\n|\r|\n/g, '\\n');
}

// 1行を75バイト以内に折り返す。続きの行は空白1つで始める。
// 文字の途中(UTF-8 のバイトの途中)では切らない
const encoder = new TextEncoder();
function foldLine(line: string): string {
  const parts: string[] = [];
  let current = '';
  let bytes = 0;
  for (const ch of line) {
    const size = encoder.encode(ch).length;
    const limit = parts.length === 0 ? 75 : 74; // 続きの行は先頭の空白で1バイト使う
    if (bytes + size > limit) {
      parts.push(current);
      current = '';
      bytes = 0;
    }
    current += ch;
    bytes += size;
  }
  parts.push(current);
  return parts.join('\r\n ');
}

export function toIcs(event: CalendarEvent, now = new Date()): string {
  const lines = [
    'BEGIN:VCALENDAR',
    'VERSION:2.0',
    'PRODID:-//example.com//Event Download//JA',
    'CALSCALE:GREGORIAN',
    'BEGIN:VEVENT',
    `UID:${event.uid}`,
    `DTSTAMP:${utcDateTime(now)}`,
    ...(event.allDay
      ? [`DTSTART;VALUE=DATE:${localDate(event.start)}`, `DTEND;VALUE=DATE:${localDate(event.end)}`]
      : [`DTSTART:${utcDateTime(event.start)}`, `DTEND:${utcDateTime(event.end)}`]),
    `SUMMARY:${escapeText(event.title)}`,
    ...(event.location ? [`LOCATION:${escapeText(event.location)}`] : []),
    ...(event.description ? [`DESCRIPTION:${escapeText(event.description)}`] : []),
    'END:VEVENT',
    'END:VCALENDAR',
  ];
  return lines.map(foldLine).join('\r\n') + '\r\n';
}

export function downloadIcs(ics: string, filename: string) {
  const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

ここから、関数の中で気をつけた点を順に見ていきます。

日時は UTC に直して、末尾に Z を付ける

DTSTART(開始)と DTEND(終了)は、20261120T010000Z のように、UTC の日時の後ろに Z を付けて書きました。RFC 5545 では、末尾の Z が「UTC の日時」を表します。Z もタイムゾーンの指定(TZID)もない日時は、特定のタイムゾーンに結びつかない「floating」な日時になり、受け取った人がいるタイムゾーンの時刻として扱うことになっています。

入力欄の日付と時刻は、new Date('2026-11-20T10:00') のようにつないで Date にしています。時差を書かない日時の文字列は、ブラウザのタイムゾーンの日時として読まれます。それを getUTCHours() などで UTC に直せば、どのタイムゾーンで見ても、同じ瞬間を指す予定になります。

同じ「11月20日 10:00〜11:30」を、America/New_York のブラウザで保存すると、次の行になりました。

text
DTSTART:20261120T150000Z
DTEND:20261120T163000Z

東京の 10:00 は UTC の 01:00、ニューヨークの 10:00 は UTC の 15:00 です。保存したファイルを ical.js で読み直すと、東京で保存したものは 2026-11-20T01:00:00.000Z から 2026-11-20T02:30:00.000Z、長さは PT1H30M(1時間30分)でした。入力したときのブラウザのタイムゾーンでの時刻どおりに入っています。

終日の予定は、終わりの日を次の日にする

「終日」にチェックを入れたときは、時刻を付けない日付(VALUE=DATE)で書きます。注意するのは DTEND です。RFC 5545 では、DTEND は予定の終わりを「その時点を含まない」形で表します。11月20日だけの終日の予定なら、終わりは11月21日です。

text
DTSTART;VALUE=DATE:20261120
DTEND;VALUE=DATE:20261121

ical.js で読み直すと、開始は 2026-11-20、終了は 2026-11-21、長さは P1D(1日)でした。

日付は、ブラウザのタイムゾーンの年・月・日(getFullYear() など)で書いています。UTC に直すと、東京の11月20日 0:00 は UTC の11月19日 15:00 になり、日付がずれるからです(東京の 10:00 が UTC の 01:00 になったのと同じく、9時間戻ります)。America/New_York のブラウザで保存しても、同じ 20261120 と 20261121 になりました。

カンマ・セミコロン・改行には \ を付ける

SUMMARY(タイトル)、LOCATION(場所)、DESCRIPTION(メモ)の値は、RFC 5545 の「TEXT」型です。この型では、\、;、, の前に \ を付けることが必須で、改行は \n の2文字で書きます。escapeText は、この4つを置き換えています。\ を最初に置き換えるのは、あとで付けた \ を二重にしないためです。

場所に 会議室A, 3階 と入れると、ファイルには LOCATION:会議室A\, 3階 と書かれ、ical.js で読み直すと 会議室A, 3階 に戻りました。メモの2行も、\n で1行に書かれ、読み直すと改行に戻っています。

この置き換えをしないとどうなるかも試しました。改行をそのまま入れたファイルは、ical.js で読めませんでした。2行目が、名前と値の組でない行になるからです。

text
ical.js のエラー: ParserError invalid line (no token ";" or ":") "資料は当日配ります"

一方、, に \ を付けずに LOCATION:会議室A, 3階 と書いたファイルは、ical.js 2.2.1 では 会議室A, 3階 と読めました。それでも RFC 5545 では \ を付けることが必須なので、どのアプリで読まれてもよいように付けています。

長い行は、75バイトごとに文字の境目で折り返す

RFC 5545 では、1行は改行を除いて75オクテット(75バイト)までにすることになっています。長い行は、CRLF の直後に空白1つを入れて、複数の行に分けます。読むときは「CRLF+空白1つ」を取り除いて、元の1行に戻します。

ひらがなやカタカナ、漢字は UTF-8 で1文字3バイトなので、文字数で数えると長さを見誤ります。foldLine は、1文字ずつ UTF-8 のバイト数を数え、75バイトを超える手前で区切っています。続きの行は先頭の空白で1バイト使うので、2行目からは74バイトまでにしました。

長いタイトルと、;・\・,・改行を含むメモを入れて保存すると、次のように折り返されました。最初の例と同じく、行を JSON の文字列として出しています。

text
"SUMMARY:第12回 React 勉強会(フォームとサーバーアクショ" (74)
" ンを中心に、実際にコードを書きながら学ぶ回)" (67)
"LOCATION:会議室A\\, 3階" (26)
"DESCRIPTION:持ち物:ノートPC\\; 充電器\\nパス C:\\\\work\\\\react を" (75)
" 使います\\n\\n終わったら懇親会\\, 参加は任意" (59)

SUMMARY の1行目が74バイトで止まっているのは、次の「ン」(3バイト)を足すと77バイトになるからです。ical.js で読み直したタイトルとメモは、入力した文字列と一致しました。

比べるために、文字の境目を気にせず、75バイトごとに切った場合も試しました。「ン」の3バイトの途中で切れてしまい、UTF-8 として読むと、その部分が置換文字(U+FFFD)になります。

text
ical.js で読んだ summary: "第12回 React 勉強会(フォームとサーバーアクショ���を中心に、実際にコードを書きながら学ぶ回)"
入力と一致: false / U+FFFD の数: 3

RFC 5545 は、文字の途中で折り返すファイルを作る実装がありうるとして、読む側に元のバイト列に戻す処理を求めています。ただ、上の検証のように、バイト列をいったん文字列に直してからパーサーに渡すと、直した時点で文字化けしてしまい、パーサーの側では元に戻せません。作る側で、文字の境目で折り返しておけば、この心配はありません。

React のコンポーネントからダウンロードさせる

コンポーネント(App.tsx)では、入力欄の値から開始と終了の Date を作り、ボタンを押したときに toIcs と downloadIcs を呼びます。次は、入力欄の JSX を省いた抜粋です。

tsx
import { useState } from 'react';
import { toIcs, downloadIcs } from './ics';

// 終日の予定の終わりは「最後の日の次の日」にする
function nextDay(date: string): Date {
  const d = new Date(`${date}T00:00`);
  d.setDate(d.getDate() + 1);
  return d;
}

export function App() {
  const [title, setTitle] = useState('React 勉強会');
  const [date, setDate] = useState('2026-11-20');
  const [startTime, setStartTime] = useState('10:00');
  const [endTime, setEndTime] = useState('11:30');
  const [allDay, setAllDay] = useState(false);
  const [location, setLocation] = useState('会議室A, 3階');
  const [description, setDescription] = useState('持ち物:ノートPC\n資料は当日配ります');

  // 日付と時刻をつないだ文字列は、ブラウザのタイムゾーンの日時として読まれる
  const start = allDay ? new Date(`${date}T00:00`) : new Date(`${date}T${startTime}`);
  const end = allDay ? nextDay(date) : new Date(`${date}T${endTime}`);
  const valid = title.trim() !== '' && !Number.isNaN(start.getTime()) && end > start;

  const handleDownload = () => {
    const ics = toIcs({
      uid: `${crypto.randomUUID()}@example.com`,
      title, start, end, allDay, location, description,
    });
    downloadIcs(ics, 'event.ics');
  };

  // …(入力欄の JSX は省略)
      <button type="button" disabled={!valid} onClick={handleDownload}>
        カレンダーに追加(.ics)
      </button>

UID と DTSTAMP は保存するたびに新しくしたい値なので、.ics の文字列は描画のたびではなく、ボタンを押したときにだけ作ります。タイトルが空のときや、終了が開始より前のとき(開始 10:00、終了 09:00 など)は、ボタンを disabled にしました。どちらも Playwright で押せないことを確かめています。

保存の処理(downloadIcs)は、Blob から URL.createObjectURL() で blob: の URL を作り、download 属性を付けた a 要素をクリックさせる書き方です。download 属性に入れた event.ics が、保存されるファイルの名前になりました。Blob の type(MIME タイプ)は、RFC 5545 で決められている text/calendar にしています。使い終わった URL は URL.revokeObjectURL() で手放します。

検証環境の Chromium では、クリックの直後に revokeObjectURL() を呼んでも、ファイルは保存されました。保存に失敗したときに理由を返す Playwright の download.failure() は、どの保存でも null(失敗なし)でした。

​Posted by 86