0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CSVをiCalendar(.ics)に変換する。RFC 5545の罠 — 75バイト折り返しと終日予定の排他的終端

0
Posted at

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 の基本構造

.icsVCALENDAR の中に予定(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値のエスケープ(, ; \ と改行)

SUMMARYDESCRIPTION のような 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/52026-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=DATEUID は最低限ファイル内で一意に

iCalendar は「ただのテキスト」に見えて、バイト単位の折り返しと排他的終端という2つの直感に反する仕様で必ず引っかかる。ここさえ押さえれば、CSVからのカレンダー一括登録はブラウザだけで完結する。CSVをサーバーに送らずに済むので、人の名前や予定が並んだ表でも安心して変換できる。

ぱんだツールズ では他にもCSV⇔Excel変換・CSV文字コード変換・CSV結合など、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?