範囲選択でGoogleカレンダーに追加するブックマークレットのソースコードを丁寧に説明してみる

2016-10-09 追記:
記事が色々まぜこぜで解りづらかったように思えたので技術の関心事ごとに分けて以下に書き直しました。
http://qiita.com/waterada/items/53ec02a521d6f49dded3
「範囲選択してGoogleカレンダーに追加するブックマークレットの作り方」




概要

日付や時刻の含まれたWebページを範囲選択して
Google カレンダーに追加するためのウィンドウが開くブックマークレット（JavaScriptを仕込んだブックマークのこと）を作ってみました。
（ただし、ES6 を使ってるので Chrome 用。）

その際、下記のようなテクニックを使ってるので、あえて晒してみることで、１つでも皆さんの参考になればと思います。
この方が良いとか、なぜこんな方法してるのとかコメントもらえると嬉しいです。

使ってるテクニック:

- ブックマークレット
- ブックマークレットから github のソースにリンクさせる
- 様々な日時を解釈する正規表現
- ES6 (ECMAScript6)
- 単体テスト
- ソースを綺麗に見せるための工夫
- メンテナンスしやすくする工夫
- そのほか、様々な細かいテクニック

とりあえずソースを、という方はこちら:
https://github.com/waterada/bookmarklet-google-calendar-add


ブックマークレットの作り方
==========================

ただのブックマークと違って、URLの代わりにJavaScriptを仕込みます。

```JavaScript
javascript:(function(d, ...others){
let s=d.createElement('script');s.src='https://waterada.github.io/bookmarklet-google-calendar-add/src.js';d.body.appendChild(s);bookmarkletToAddToGoogleCalendar(...others);
})(document, window.getSelection()+'', window.open)
```

- 【bookmarklet】`javascript:` で始めることで JavaScript を動かすことができます。
- 【bookmarklet】`(function(～){ ～ })(～)` のように関数で囲むんで実行させることでグローバルな名前空間を汚さないようにしています。
- 【bookmarklet】また、その際、`(document, window.getSelection()+'', window.open)` のようにグローバルなものをすべて引数で渡すことで外部との結合を明らかにし、もっと複雑な馬合にはブックマーク文字列を短くなる効果も期待できます。
- 【JavaScript】`window.getSelection()` は選択範囲をオブジェクトで返します。`+''` によって手っ取り早く文字列化しています。
- 【JavaScript】`window.open` は新しいウィンドウをポップアップさせるJavaScriptのメソッドです。
- 【ES6】`function(d, ...others){` のように引数に `, ...others` をつけることで残りの引数がすべて `others` という配列の中に格納されます。つまり `function(d, text, open){ var others = [text, open];` と書いたのと同じです。
- 【ES6】`let s=` は `var s=` とほぼ同等ですが、`var` は `function(){ ～ }` のスコープであるのに対し、`let` は `{ ～ }` のスコープです。ES6では通常こちらを使います。
- 【bookmarklet】`let s=d.createElement('script');s.src='～URL～';d.body.appendChild(s);` で、動かしたい外部スクリプトをページに差し込みます。こうすることでブックマークレットのロジックを外に出せます。
- 【ES6】`bookmarkletToAddToGoogleCalendar(...others)` のように引数に `...` を使うと、`others` の中身が引数として展開されます。つまり `bookmarkletToAddToGoogleCalendar(others[0], others[1])` と書いたのと同じです。
- 【github】`https://waterada.github.io/bookmarklet-google-calendar-add/src.js` は github のリポジトリを web サーバ的に公開できる仕組みを使っています。`https://github.com/waterada/bookmarklet-google-calendar-add/` というリポジトリに `gh-pages` というブランチを作成することで `https://waterada.github.io/bookmarklet-google-calendar-add/` でアクセスできるようになります。


メインのソース
==========================

```JavaScript
function bookmarkletToAddToGoogleCalendar(selected, open, NOW) {
    
}
```
- 【bookmarklet】名前空間をできるだけ汚さないように外側を関数で囲んであります。
- 【単体テスト】また、テストしやすいように外部依存のあるもの（選択文字列、ポップアップメソッド、現在時刻）は引数で受け取っています。

```JavaScript
    NOW = NOW || new Date();
```

- 【JavaScript】第３引数 `NOW` が省略されたら（普通は省略）、現在時刻を使うようにします。現在時刻を外から渡せないと単体テストがやりづらくなるので、このようにします。

```JavaScript
    class RegExpSweet {
        constructor() {
            this.replaces = [];
        }
        addSyntax(replacements) {
            for (let search of Object.keys(replacements)) {
                let replace = replacements[search];
                if (search.match(/^\w+$/)) { search = `\\b${search}\\b`; }
                search = new RegExp(search, 'g');
                this.replaces.unshift({search, replace});
            }
            return this;
        }
        toRegExp(regExpStr, flags) {
            for (let a of this.replaces) {
                regExpStr = regExpStr.replace(a.search, a.replace);
            }
            return new RegExp(regExpStr, flags || 'g');
        }
    }
    const reSweetDate = new RegExpSweet();
    reSweetDate.addSyntax({'（': '[（\\(]', '）': '[\\)）]'});
    reSweetDate.addSyntax({' ': '\\s*'});
    reSweetDate.addSyntax({'  ': '\\s+'});
    reSweetDate.addSyntax({'<': '(?:', '>': ')?'});
    reSweetDate.addSyntax({
        WEEK: '（[月火水木金土日]）',
        TO: '(?:から|～|-|－)',
        D2: '\\d{1,2}',
        HEISEI: '(?:平成?|[Hh]\\.?)',
        SHOWA: '(?:昭和?|[Ss]\\.?)',
        YYYY: `(?:\\d{4}|(?:HEISEI|SHOWA) \\d{1,2})`,
        TIME_JA: '(D2)時 <(D2分|半) <D2秒>>',
        TIME_EN: '(D2):(D2)<:D2>',
    });
```

- 【テクニック】`RegExpSweet` は正規表現をメンテしやすくするためのものです。お決まりのセンテンスを定義しておくことで正規表現を書く際のケアレスミスを防ぎ、可読性をあげます。コーディングシュガー的なやつです。たとえば 
    - ` ` → `\s*`  (空白0個以上の意味)
    - `<` → `(?:`  (省略可能の開始カッコ)
    - `>` → `)?`  (省略可能の終了カッコ)
    と定義しておくことで `(A)(?:\s*(B)(?:\s*(C))?)?` が `(A)< (B)< (C)>>` と書けます。これを実現するためのクラスを即席で定義しています。利用するときには `reSweetDate.toRegExp('(A)< (B)< (C)>>')` として正式な正規表現に変換します。
- 【ES6】ES6 では `class` 構文が使えます。便利ですね。`function() {}` の中で定義すれば、それはその中だけのスコープになります。
- 【ES6】`class` の中では `constructor()` でコンストラクタを定義します。
- 【ES6】`for (let 変数 of イテレータ可能なオブジェクト) {` で従来の `for (var 変数 in オブジェクト) {` よりも的確にループできます。 
- 【ES6】`Object.keys(replacements)` で連想配列からキーの配列を取り出せます。
- 【ES6】`` `\\b${search}\\b` `` のように `` `～` `` で囲むことで `${～}` を展開させることができます。つまり `'\\b' + search + '\\b'` と書いたのと同じことになります。
- 【ES6】`{search, replace}` のようにオブジェクトリテラルに変数を渡すと、`{search: search, replace: replace}` と書くのと同じ意味になります。冗長な表記が減り、その分、重要な部分が紛れづらくなります。
- 【正規表現】`if (search.match(/^\w+$/)) {` は単語に使う文字(a-zA-Z0-9_)のみで構成されていたらという意味です。


```JavaScript
    const RE_DATES = [
        '<(YYYY)年> (D2)月 (D2)日 <WEEK> ',
        '<(YYYY)/>(D2)/(D2)(?: WEEK |\\b )',
        '(YYYY)-(D2)-(D2)(?: WEEK |\\b )',
        '<(YYYY)\\.>(D2)\\.(D2)(?: WEEK |\\b )',
    ];
    const RE_TIMES = [
        '(D2)() TO (D2)() 時',
        'TIME_JA <TO TIME_JA>',
        'TIME_EN <TO TIME_EN>',
        '()()()()',
    ];
```

- 【説明】日の書き方と、時の書き方のパターンを抜き出して定義しています。あとですべての組み合わせを作ってあとで総当りチェックさせます。
- 【説明】一番下の `()()()()` は、時刻が存在しない場合のパターンです。後の処理でｎ番目のカッコの中身という具合に取得するので `()` の数を同じにする必要があるのでこのようにしています。
- 【ES6】`const` は定数を定義します。`let` と同様 `{ ～ }` のスコープです。違いは書き換えられません。ただし、定数そのものが書き換えられないだけでプロパティや配列の要素は書き換え可能です。

```JavaScript
    let dtReList = [];
    for (let d of RE_DATES) {
        for (let t of RE_TIMES) {
            let re = reSweetDate.toRegExp(`\\s*${d}${t}\\s*`);
            dtReList.push(re);
        }
    }
```

- 【テクニック】日と時、４つずつあるパターンの総当りを作っています。16パターンの正規表現が作られます。パターンが増えると掛け算で総数が増えることになりますが、ブックマークレットという使用方法なら、微々たるパフォーマンスよりもメンテナンス性が優先されるのでこのようにしています。選択範囲はそれほど大きくもならないので、パフォーマンス犠牲といっても判らないほどでしょう。

```JavaScript
    const zf = n => ('0' + n).slice(-2);
```

- 【JavaScript】`('0' + n).slice(-2)` は２桁でゼロ埋めする一般的な手法です。
- 【ES6】`const zf = n => ～ ;` はラムダ式で関数 zf を定義しています。`const zf = function (n) { return ～; };` と書くのとほぼ同じです。ラムダ式はロジックを `function` などの長い文字列なしに端的に表現できます。文法のための文字が減ることで、ロジックが見やすくなります。さきほど「ほぼ」同じと言ったのは `this` が置き換わらない点と、`var` のスコープ対象にならない点が異なるためです。


```JavaScript
    const analyzeYmd = (is2nd, y, m, d) => {
        let dt = new Date(`${y}-${m}-${d}`);
        if (isNaN(dt)) { return; }
        if (is2nd) { //日時の２つ目は翌日
            dt = new Date(dt.getTime() + 24 * 3600 * 1000);
        }
        return {
            hasHi: false,
            str: `${y}${zf(dt.getMonth()+1)}${zf(dt.getDate())}`
        };
    };
```

- 【説明】引数 `y`, `m`, `d` を受け取って、日付として正しいかを評価し、正しければ Google カレンダーに渡せる形式の日付文字列（を含むオブジェクト）を返し、正しくなければ何も返さない関数です。
- 【JavaScript】`isNaN(dt)` は Date 型で日付が不正な場合に true になるので利用しています。
- 【Googleカレンダー】時刻がない場合は `yyyymmdd/yyyymmdd` という文字列で日付範囲を指定することになります。そのとき、２つ目の日付は翌日にする必要があります。つまり 2000/1/1～2000/1/2 なら `20000101/20000103` となります。
- 【JavaScript】`dt = new Date(dt.getTime() + 24 * 3600 * 1000)` は翌日にする一般的な方法です。


```JavaScript
    const analyzeYmdhi = (is2nd, y, m, d, h, i) => {
        i = i || '00';
        if (i === '半') { i = '30'; }
        i = i.replace(/分$/, '');
        let dt = new Date(`${y}-${m}-${d} ${h}:${i}`);
        if (isNaN(dt)) { //時間とっても成立か
            return analyzeYmd(is2nd, y, m, d);
        }
        return {
            hasHi: true,
            str: dt.toISOString().replace(/(:|-|\.\d+)/g, '')
        };
    };
```

- 【説明】引数 `y`, `m`, `d`, `h`, `i` を受け取って、日時として正しいかを評価し、正しければ Google カレンダーに渡せる形式の日付文字列（を含むオブジェクト）を返します。正しくなければ日付だけで解釈可能かを試みます。
- 【説明】`i` は「分」の意味です。`m` では「月」と被りますし、mysql や php などの日付フォーマットでは `ymdhis` と表現されることが多いため、ここでは `i` という名前をつけています。
- 【Googleカレンダー】時刻がある場合は `yyyymmddThhii00Z/yyyymmddThhii00Z` という文字列で日付範囲を指定することになります。GMTのタイムゾーンで指定する必要があります。２つ目の日付は特に翌日にする必要はありません。2000/1/1 09:00～2000/1/1 10:00 なら `20000101T000000Z/20000101T010000Z` となります。
- 【テクニック】`dt.toISOString()` は GMT のタイムゾーンで yyyy-mm-ddThh:ii:ss.mmmZ という文字列を返しますので、ここから `.replace(/(:|-|\.\d+)/g, '')` で余分な文字を撤去して、欲しい文字列を作っています。これが一番簡単かなと思いました。



```JavaScript
    let date1 = null, date2 = null;
    const pickupDate = (...args) => {
        if (date2) { return ''; }
        let [a, y, m, d, h, i, h2, i2] = args;
        y = y || (date1 && date1.args[1]) || NOW.getFullYear();
        if (y.replace) {
            y = y.replace(reSweetDate.toRegExp('^HEISEI (\\d+)'), (a, y) => y * 1 + 1988);
            y = y.replace(reSweetDate.toRegExp('^SHOWA (\\d+)'), (a, y) => y * 1 + 1925);
        }
        let obj;
        if (h) {
            obj = analyzeYmdhi(!!date1, y, m, d, h, i);
        } else {
            obj = analyzeYmd(!!date1, y, m, d);
        }
        if (!obj) { return a; }
        if (date1 && date1.hasHi != obj.hasHi) { return a; } //前と書式が違うなら
        if (date1) {
            date2 = obj;
        } else {
            obj.args = args;
            date1 = obj;
        }
        if (h2) {
            pickupDate(a, y, m, d, h2, i2);
        }
        return '';
    };
```

- 【説明】`pickupDate()` は選択範囲内で日付がヒットするたびに呼ばれる関数です。`replace()` の第２引数で置き換え文字列の代わりに渡します。関数の中では１つ目にヒットした日時は `date1` に、２つ目は `date2` にセットし、３つ目以降は無視します。
- 【テクニック？】最初は `dates` という配列を使って `dates[0]` とやってたのですが、結局は `date1` のほうが直感的で読みやすくなったので、こちらにしました。
- 【ES6】`let [a, y, m, d, h, i, h2, i2] = args;` は、`args` の中身を順に変数に割り当てます。`a` には `args[0]` の値が入り、`y` には `args[1]` の値が入ります。
- 【JavaScript】`y = y || (date1 && date1.args[1]) || NOW.getFullYear();` は `y` があれば `y` 、なければ `(date1 && date1.args[1])`、これもなければ `NOW.getFullYear()` というようにデフォルト値を `||` 区切りで表記できます。
- 【JavaScript】`y = y.replace(reSweetDate.toRegExp('^HEISEI (\\d+)'), (a, y) => y * 1 + 1988)` は、「平成」という文字の置き換えを試みて、ヒットしたらコールバックが呼ばれる仕組みです。コールバックでは、`a` にヒットした文字列全体が、`y` には最初のカッコの値が(文字列で)入りますので、`* 1` で数値化して `1988` を足すことで西暦化しています。
- 【JavaScript】`analyzeYmdhi(!!date1, y, m, d, h, i)` に `!!` という演算子が出てきて驚いたかもしれません。これは `!` を２回行うことで `(date1 ? true : false)` と同じ意味にしています。つまり 明示的に boolean にしています。


途中ですが
============

一旦ここまででアップしてみます。
書きながら説明しづらいなーっと感じたところは適宜、リファクタしながら進めました。
その際も単体テストがあるのでリファクタが非常に楽です。単体テストで楽になるのを知らない人はぜひ知ってもらいたいところですね。
反響があれば、次回は後半部分と、単体テストの説明を書こうと思います。