3行まとめ
- CSVの予定一覧を iCalendar(.ics) に変換するツールをブラウザ完結で作った。Google/Apple カレンダーにインポートできる
- iCalendar(RFC 5545)は地雷が多い。行は75"バイト"で折り返し(文字数ではない・日本語は3バイト)、改行は CRLF 必須、TEXT値は
, ; \をエスケープ - 一番ハマるのが 終日予定の DTEND は"排他的終端"。1日だけの予定でも終了日は翌日にしないと、カレンダー上で2日間に伸びる
CSVで管理している予定一覧(タイトル・日付・場所…)を、そのままカレンダーに取り込みたい。Google カレンダーや Apple カレンダーは .ics(iCalendar)ファイルをインポートできるので、CSV → .ics 変換さえできれば一括登録できる。
ぱんだツールズに作った CSV→iCal 変換ツールは、これをブラウザ内でやる。CSVのパースは papaparse で済むが、.ics の生成側が RFC 5545 の細かい仕様だらけで、ここが本体。実装しながら踏んだ罠を中心にまとめる。
iCalendar の基本構造
.ics は VCALENDAR の中に予定(VEVENT)を並べたテキスト形式。最小構成はこう。
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//ぱんだツールズ//CSV to iCal//JA
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:20260605-001@example.com
DTSTART;VALUE=DATE:20260605
DTEND;VALUE=DATE:20260606
SUMMARY:打ち合わせ
END:VEVENT
END:VCALENDAR
見た目は素朴だが、ここから先の「文字列をどう組み立てるか」に RFC 5545 の地雷が埋まっている。順に潰していく。
罠1:改行は CRLF 必須・各行は75"バイト"で折り返す
iCalendar の改行は CRLF(\r\n)固定。LF だけだと厳格なパーサに弾かれる。なので全行を \r\n で join する。
もっと厄介なのが 「1行は75オクテット(バイト)まで」 という折り返し規定。これを超える行は途中で改行し、継続行の先頭にスペース1個を置く(折り畳み = line folding)。
ここの罠は 「75文字」ではなく「75バイト」 であること。日本語は UTF-8 で1文字3バイトなので、文字数で数えると簡単にオーバーする。マルチバイト文字の途中でぶった切るとファイルが壊れるため、バイト境界を意識して切る必要がある。
UTF-8 のバイト長を、TextEncoder を使わずに文字コードから直接数える。
function utf8ByteLength(str: string): number {
let len = 0
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i)
if (code < 0x80) len += 1
else if (code < 0x800) len += 2
else if (code >= 0xd800 && code <= 0xdbff) { len += 4; i++ } // サロゲートペア
else len += 3
}
return len
}
ASCII は1バイト、ラテン拡張系は2バイト、サロゲートペア(絵文字など)は4バイトで i をもう1つ進める、それ以外(日本語の大半)は3バイト。これで実バイト長が出る。
折り返し本体は、この長さを見ながら75バイト(継続行は先頭スペース分を引いて74バイト)でチャンクに割る。
function foldLine(line: string): string {
if (utf8ByteLength(line) <= 75) return line
const result: string[] = []
let remaining = line
let maxBytes = 75
while (utf8ByteLength(remaining) > maxBytes) {
const [chunk, rest] = sliceAtByteLen(remaining, maxBytes)
result.push(chunk)
remaining = rest
maxBytes = 74 // 継続行は先頭スペース1バイト分を引く
}
result.push(remaining)
return result.join('\r\n ') // CRLF + 半角スペースで継続
}
sliceAtByteLen は「先頭から maxBytes バイト以内」で文字を切るヘルパーで、utf8ByteLength と同じバイト計算をしながら、超える直前で止める(サロゲートペアは2コードユニット単位で進める)。継続行を \r\n で繋ぐのがポイントで、行頭スペースが「これは前の行の続き」を意味する。
罠2:TEXT値のエスケープ(, ; \ と改行)
SUMMARY や DESCRIPTION のような TEXT 値には、エスケープが要る文字がある。RFC 5545 で定められているのは4種。
function escapeIcsText(text: string): string {
return text
.replace(/\\/g, '\\\\') // \ → \\
.replace(/,/g, '\\,') // , → \,
.replace(/;/g, '\\;') // ; → \;
.replace(/\r\n|\r|\n/g, '\\n') // 改行 → \n
}
, と ; はプロパティ内のパラメータ区切りに使われる予約文字なので、値に含めるならエスケープが必須。これを忘れると、カンマ入りのタイトル(「会議、その後懇親会」)がそこで切れて壊れる。バックスラッシュを最初に処理するのは、後段で挿入する \ を二重エスケープしないため(,→\, を先にやると、その \ を後の \→\\ がもう一度食う)。
SUMMARY/DESCRIPTION/LOCATION は、エスケープしてから前述の foldLine に通す。順序は エスケープ → 折り返し。
lines.push(foldLine(`SUMMARY:${escapeIcsText(row.title.trim())}`))
罠3:終日予定の DTEND は"排他的終端"(翌日にする)
これが一番ハマる。終日予定(時刻なし)の DTEND は、最終日の"翌日"を指定する。RFC 5545 では DTEND が排他的(exclusive)= 「その日は含まない終端」と定義されているため。
つまり「6月5日の1日だけ」の終日予定は、
DTSTART;VALUE=DATE:20260605
DTEND;VALUE=DATE:20260606 ← 翌日!
と書く。ここを DTEND:20260605 にすると、カレンダーによっては予定が表示されなかったり、逆に DTEND を当日にしたつもりが2日間にまたがって表示されたりする。直感に反するので、知らないと必ず間違える。
実装では、終了日が未指定なら開始日の翌日を計算して入れる。月またぎ・年またぎを Date に任せるのがポイント。
// 終日予定で終了日の指定がない → 開始日の翌日を排他的終端にする
const y = parseInt(startDateStr.slice(0, 4), 10)
const m = parseInt(startDateStr.slice(4, 6), 10) - 1 // Dateは月0始まり
const d = parseInt(startDateStr.slice(6, 8), 10)
const next = new Date(y, m, d + 1) // d+1 で翌日。月末は自動繰り上げ
const nextStr = `${next.getFullYear()}${String(next.getMonth() + 1).padStart(2, '0')}${String(next.getDate()).padStart(2, '0')}`
lines.push(`DTEND;VALUE=DATE:${nextStr}`)
new Date(y, m, d + 1) は、d + 1 が月末を超えても勝手に翌月へ繰り上げてくれる(6/30 + 1 → 7/1)。日付計算を自前でやらず Date に任せると、閏年も月末も気にしなくていい。
時刻あり予定はタイムゾーン付き・終日予定は VALUE=DATE
時刻の有無で書式が変わる。
-
時刻あり:
DTSTART;TZID=Asia/Tokyo:20260605T093000のように、IANA タイムゾーン ID を付けて日付Tの時刻形式 -
終日:
DTSTART;VALUE=DATE:20260605のようにVALUE=DATEを付けて日付だけ
if (hasStartTime) {
lines.push(`DTSTART;TZID=Asia/Tokyo:${startDateStr}T${startTimeStr}`)
// 終了未指定なら開始1時間後をデフォルトに
} else {
lines.push(`DTSTART;VALUE=DATE:${startDateStr}`)
}
時刻ありで終了時刻が未指定のときは、開始の1時間後をデフォルトにしている((hour + 1) % 24 で時の桁だけ+1して24時跨ぎも処理)。日付は YYYYMMDD、時刻は HHmmss に正規化してから埋め込む(2026/6/5 も 2026-06-05 も受けて 20260605 に揃える)。
なお TZID=Asia/Tokyo について1点正直に書いておくと、このツールは VTIMEZONE ブロックを出力していない。RFC 5545 を厳密に読むと TZID は本来ファイル内の VTIMEZONE 定義への参照なので、これは厳密準拠ではない。ただ Google カレンダー・Apple カレンダーをはじめ主要クライアントは IANA タイムゾーン名(Asia/Tokyo)を直接解決してくれるので、実用上は日本時間として取り込める。厳格なバリデータに通す前提なら VTIMEZONE を別途付ける必要がある、という割り切り。
UID は予定ごとに一意にする
各 VEVENT には UID が要る。これは予定を一意に識別する ID で、再インポート時の重複判定などに使われる。連番+ドメイン(部分は任意の自ドメインでよい)で生成している。
const seq = String(i + 1).padStart(3, '0')
const uid = `${timestampBase}-${seq}@sakutto-panda.com`
UID が重複すると「同じ予定」とみなされて上書き・スキップされることがあるので、最低限ファイル内では衝突しないようにする。
まとめ
CSV → iCalendar(.ics) 変換で踏む RFC 5545 の罠まとめ。
- 改行は CRLF(
\r\n)必須。行は 75"バイト"で折り返し(文字数ではない。日本語3バイト・絵文字4バイトをバイト境界で切る)。継続行は\r\nで先頭スペース - TEXT値は
\,;改行 をエスケープ。バックスラッシュを最初に処理。順序はエスケープ→折り返し -
終日予定の DTEND は排他的終端 → 翌日。
new Date(y, m, d+1)で月またぎも自動。ここが最頻出のバグ - 時刻あり =
TZID=Asia/Tokyo付きT形式、終日 =VALUE=DATE。UIDは最低限ファイル内で一意に
iCalendar は「ただのテキスト」に見えて、バイト単位の折り返しと排他的終端という2つの直感に反する仕様で必ず引っかかる。ここさえ押さえれば、CSVからのカレンダー一括登録はブラウザだけで完結する。CSVをサーバーに送らずに済むので、人の名前や予定が並んだ表でも安心して変換できる。
ぱんだツールズ では他にもCSV⇔Excel変換・CSV文字コード変換・CSV結合など、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。