いい感じのiCalパーサが主にライセンス的な縛りでなかったので自分で書き始めたらおもいのほかハマったので愚痴を残しておく
上のソースコードリーディングから始めたが、結果的に実装はだいぶ別物になった
できたもの
グダグダ文句たれるまえに実装をさらしていくスタイル
以下延々とiCalendarについて愚痴る
iCalendarとは?
メールについてるイベントデータ。会議とかする人はもう日常的に使ってるやつ
RFC 5545にいろいろ定義されている
構造
ざっくり以下のような構造になっている
BEGIN:VCALENDAR
いろいろプロパティ
BEGIN:TIMEZONE
いろいろプロパティ
BEGIN:STANDARD
いろいろプロパティ
END:STANDARD
BEGIN:DAYLIGHT
いろいろプロパティ
END:DAYLIGHT
BEGIN:EVENT
いろいろプロパティ
END:EVENT
END:TIMEZONE
END:VCALENDAR
もう少しわかりやすいように階層化した感じにすると以下のようになる
├─ BEGIN:VCALENDAR
│ ├─ プロパティ
│ ├─ BEGIN:TIMEZONE
│ │ ├─ プロパティ
│ │ ├─ BEGIN:STANDARD
│ │ │ ├─ プロパティ
│ │ ├─ END:STANDARD
│ │ ├─ BEGIN:DAYLIGHT
│ │ │ ├─ プロパティ
│ │ ├─ END:DAYLIGHT
│ ├─ END:TIMEZONE
│ ├─ BEGIN:EVENT
│ │ ├─ プロパティ
│ ├─ END:EVENT
├─ END:VCALENDAR
これを構造化してアクセスできるようにするのが目標。
複数行にまたがる行の折りたたみ
RFC 5545の仕様上先頭が半角空白で始まる行は前の行の続きなので、実際にパースする前に折りたたむ必要がある。これは比較的さくっと実装できる。
let outputLines = []
// 複数行の連結用に、前の行を保存するための変数
let lineBuffer = ""
lines.split(/\r?\n/).forEach(line => {
// 行頭に半角空白が入っていたら前の行と結合する
if (line.match(/^ +.*/)) {
lineBuffer = lineBuffer + line.replace(/^ +/, "")
} else {
if (lineBuffer !== "") {
outputLines.push(lineBuffer)
lineBuffer = ""
}
lineBuffer = line
}
})
if (lineBuffer !== "")
outputLines.push(lineBuffer)
パース
いわゆるDFT(深さ優先探索)パーサを書けばいいわけだが、過去何回かパーサを書いたことはあるもののこれがベスプラ、という書き方がわからなかったので上記のコードリーディングをしていた。
結論を言うと、子コンポーネントを持つことができるクラスを作って、ネストが深くなるたびにスタックに積み上げ、子コンポーネントが閉じたら親コンポーネントの子として格納する、という方式でいい感じに書くことができた。
サンプルで言うと以下の部分
let stack = []
let comps = []
unfoldedLines.forEach(line => {
const [name, params, value] = this.parts(line)
if (name.toUpperCase() === "BEGIN") {
// 新しいコンポーネントの開始フラグが来たら、
// クラスを作ってスタックに積み上げる
const c_name = params.toUpperCase()
const c_class = Component.getFactory().getComponentClass(c_name)
stack.push(new c_class(c_name))
} else if (name.toUpperCase() === "END") {
// 終了フラグが来たら、
// スタックの末尾をその親の子コンポーネントにする
if (stack.length == 0) {
throw new Error("END encountered without an accompanying BEGIN!")
}
const component = stack.pop()
if (stack.length == 0) {
comps.push(component)
} else {
stack[stack.length -1].addComponent(component)
}
} else {
// パラメータ行はスタックの末尾のコンポーネントに
// 格納する
const component = stack[stack.length -1]
if (component === undefined && name !== "X-COMMENT") {
throw new Error(`Property ${name} does not have a parent component.`)
}
component.addParametersFromString(name, params)
}
})
ベンダー独自パラメータ
どことは言わないけど、独自のパラメータをこれでもかと入れてくるMUAもいる。
いちおうリファレンスも公開されてはいるけど、これ見てどないせいっちゅうねん、という感じ。オープンとは・・・。
しょうがないので意味も分からず格納するだけしておく。
TIMEZONE処理
今回一番ハマったところ
そもそもTIMEZONEをiCalendarレイヤで持つ必要はない。全部UTCで書けば不要
まずそもそも論を言うと、「iCalendarのレイヤでTIMEZONEを有する必然性はない」。内部データをUTCで持たせておけば、そのデータの利用者であるMUAは表示するための適切なタイムゾーンに変換することはできる。
実際いくつかのiCalデータを生成するサイトでは全ての時刻指定をUTCでやっているところもある。私もこれが妥当だと思う(参考)。Gmailは内部データは全てUTCでやってるらしい。さっすが。
で、その状況であえてタイムゾーン情報をiCalの中で処理しないといけない理由は、自分の理解では
-
一般的に知られていないタイムゾーンで時刻指定をしてくるMUA対応
例えば一般的に日本時間は
JSTとかAsia/Tokyoだが、世の中のMUAにはTokyo Standard Timeなる謎のタイムゾーン情報でイベントの開始時間を指定してくるMUAがいる。こいつのことだけど。これを正しく解釈しなければいけない -
過去のiCalデータ対応
あまり現実的な例ではないが、現在のタイムゾーンデータが使わないような過去のサマータイムなど、例外的なタイムゾーン情報を使ってイベントの開始時刻などを指定する場合。
・・・ということで必要のない処理をあえて書かなければいけないのでモチベーションが上がりづらい。
TIMEZONEのハマりどころ
仕方ないのでTIMEZONEの解釈を始めるわけだが、ハマりどころがいくつもある
-
イベントの開始時刻は「UTC」か「wall time」
UTCはともかくとして「wall time」ってなんだよというと、そのタイムゾーンにおける実際の時計の時刻、という意味。なんだそれってオフセット入れればいいじゃん、って考えがちだが、実はそう単純なものではない。
-
多くの地域では「標準時」と「サマータイム」がある
サマータイムが何かというのはだいたいみんな知ってると思う。英語だと "Daylight Saving Time(DST)" とも言われている。
つまりイベントの開始時刻が標準時なのかサマータイムなのかを判別するロジックを入れなければいけない。もちろん標準時なのかサマータイムなのかによってオフセットは変わる。
-
しかもサマータイムは国ごと、年ごとに開始終了日時が異なる
実は日本でも過去サマータイムが実施されていたことを知っている人は少ないと思う(参照)。見てもらうと、年ごとにサマータイムの開始日が異なっていることに気づくと思う。
上記の日本の例では開始 "日" の指定しかない(つまり開始時刻は 00:00)が、海外では開始時刻の指定もある。例えば太平洋夏時間は開始日の午前2:00から開始する(参照)。
南半球も考慮に入れる必要がある。例えばオーストラリアの夏時間は10月第一日曜日午前2:00に始まり、4月第一日曜日午前3:00に終わる(参照)。
イギリスはもっとひどくて、過去の夏時間の実施に規則性がない時期があった(参照(特に1940年〜1947年))
-
サマータイムの開始/終了日は「xx月の第yy-zz曜日」となる
過去の日本の場合は上記の通り、「5月の第1日曜日」に開始して、「9月の第2土曜日」に終わる。つまり上記の通り 00:00 開始の 00:00 終了になるのだが、太平洋標準時(PST)の場合は「3月の第2日曜の午前2:00」に開始して、「11月の第1日曜日の午前2:00」に終わる。
これが何を意味するかと言うと、任意の年のサマータイムの開始/終了日は決め打ちではなく、毎回計算しなければいけないことを意味する。
iCalデータには
RRULEというプロパティがあってこの計算をするための情報が提供されている・・・いるのだが(続く) -
iCalの
RRULEはイベントの繰り返し指定を使いまわしているので定義が冗長開始月を指定する
BYMONTHや、上記の何日から開始するかを指定するBYDAY、どの程度の頻度で繰り返すかを指定するFREQオプションはいいとして、第何週かを指定するBYWEEKNOとか、繰り返しの何回目から指定を有効化するかを指定するBYSETPOSとか・・・まじめに全てのオプションに対応しようとすると頭がおかしくなりそう。しかも1回限りの開始日を指定する
RDATEオプションがRRULEと同レベルに存在するので、上書き処理も考慮する必要がある・・・。実際に使われている例をできるだけ網羅して、使われるオプションだけ実装するのが現実的だと思う。
-
サマータイムの開始、終了時にある "時刻のジャンプ(gap)" と "同時刻の重複(fold)"
サマータイムはその性質上、開始時には現地の時計を強制的に1時間進めるため、時刻がジャンプし、存在しない時刻が発生する。
例えば以下の表は太平洋標準時(PST)における夏時間開始時の現地時刻とUTCとの関係。UTCは連続しているのに、PST/PDTでは 02:00 が存在しない。正確に言うと、PSTにおける02:00 イコール PDTの03:00になる。これが "時刻のジャンプ(gap)"。
PST/PDT UTC 2025-03-09 00:00(PST) 2025-03-09 08:00 2025-03-09 01:00(PST) 2025-03-09 09:00 2025-03-09 03:00(PDT) 2025-03-09 10:00 2025-03-09 04:00(PDT) 2025-03-09 11:00 同様にサマータイムの終了時には時刻が1時間巻き戻る。同様に太平洋夏時間(PDT)が終了するときの現地時刻とUTCとの関係を表にしたものが以下のもの。同様にUTCは連続しているのに、PST/PDTでは 01:00 が2回発生している。正確にはPDTの 02:00 がイコール PST の 01:00 になる。これが "同時刻の重複(fold)" になる。
PST/PDT UTC 2025-11-02 00:00(PDT) 2025-11-02 07:00 2025-11-02 01:00(PDT) 2025-11-02 08:00 2025-11-02 01:00(PST) 2025-11-02 09:00 2025-11-02 02:00(PST) 2025-11-02 10:00 しかもこの gap や fold をどのように処理すべきかはRFCでは定義がなく、実装に委ねられている。もっというと、ユーザが指定できるようにすべき。つまり「太平洋時間の 2025-11-02 01:00」が与えられたときに「PSTの時間として扱い、UTCの2025-11-02 09:00」と解釈すべきか、「PDTの時間として扱い、UTCの2025-11-02 08:00」と解釈すべきかに決まったルールはない。一般的には「より早く出現した方(上記の例では 2025-11-02 01:00(PDT))」を採用する例が多いらしい。
さらにタイムゾーン指定してるのに参照するデータがない場合もあるらしい
まだ実際のデータに遭遇してないけど、過去のiCalデータには、イベントの開始日時がタイムゾーン指定で書かれているものの、参照するタイムゾーンデータがない、ということもあるらしい。
どういうことかというと、過去ローカルPCの中に、全てのタイムゾーンデータ(例えば zoneinfo)が入っているという前提で、そのように書かれているものがあるらしい。
つまり、過去に遡って全てのiCalデータを解釈するためにはライブラリ内に全てのタイムゾーンのデータをあらかじめ持っておく必要がある。
さすがにこの部分は今回実装していない。
ということでiCalパーサの実装はひたすらにしんどいということがわかりました
実際上記全て加味して実装するのに試行錯誤して8日ほどかかった・・・。もうしばらく見たくもねえ・・・
というかだいたいRFC5545(2445)が諸悪の根源なんじゃないの?
iCal自体がかなり以前のものなのでどうしようもないということはわかるんだけど
- そもそも構造がJSONやXMLじゃないので自前でパーサを書くハメになる
- epoch time以前を明示的に拒否しなかったため実装側が1970年以前を(実質的に時差という観点では無為なものにも関わらず)切り捨てられない(参考)
- 全体を通して仕様が統一されていないところが散見される
- 配列にすべきところがそうなっていない(EVENTの複数ATTENDEEなど。ここは子コンポーネントにしても良かったのでは)
- RRULEとか流用されているために仕様の解釈の余地が大きすぎてパーサ書く側が死ぬ
- ベンダー独自オプションを認めすぎていて実データがカオス。しかも追加した側の説明もない。(上では触れてないけどベンダー独自コンポーネントとかも仕様上OKにしてる。マジかよ・・・)で、そのアプリケーションがドミナント取ると追従コストが無駄に大きくなる(まあRFC2445の著者を見ればお察しなところはある)
- TIMEZONEのあれこれ。UTCで統一でいいだろだいたい1998年にはUTCもあったしタイムゾーンがひどい状況ってのはわかってたはずだろ
最初の仕様策定時に避けられたところはあったかもしれないけど、曖昧さオプションの多さを取り過去フォーマット独自フォーマットが広まった結果とんでもない状況になってしまった感はある。今さらRFCを改定したところで過去のアレの山と戦うことは避けられないというどうにもし難い状況。めちゃくちゃ更地にしてえ・・・
おわりです
次回はいろいろ頼むな!IETF!