範囲選択してGoogleカレンダーに追加するブックマークレットの作り方

前回書いた記事が技術的にまぜこぜになっていたので解りやすく技術毎に分離してみました。
ここでは google カレンダー追加の bookmarklet の作り方 ～ テストの回し方を解説します。

# 1. 最低限で実現(これだけで実現できます)

これだけで、選択された日付を読み取ってgoogleカレンダーをポップアップさせることができます。
ただし、これだと new Date() で解釈できない日付には対応できません。

```JavaScript:bookmark
javascript:(function(sel, open){
var dt = new Date(sel).toISOString().replace(/(:|-|\.\d+)/g, '');
open('http://www.google.com/calendar/event?action=TEMPLATE&trp=false&dates=' + dt  + '/' + dt);
})(window.getSelection()+'', window.open)
```

# 2. ブックマークレットで外部ソースを参照させる

これから機能を増やしていくにあたり、git管理もしない、単体テストも書けない、ではやりづらいので、外部に切り出します。

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

`https://waterada.github.io/bookmarklet-google-calendar-add/src.js` は github 上に公開されたソースです:

```JavaScript:src.js
function bookmarkletToAddToGoogleCalendar(sel, open) {
    var dt = new Date(sel).toISOString().replace(/(:|-|\.\d+)/g, '');
    var url = 'http://www.google.com/calendar/event?action=TEMPLATE&trp=false' +
        '&dates=' + dt  + '/' + dt;
    open(url);
} 
```

https://github.com/waterada/bookmarklet-google-calendar-add/ に `gh-pages` というブランチ名で公開することで、上記のように public な html や js を公開できます。


# 3. テスト追加

テストしやすようにgoogleに渡している日付文字列を返すようにします。

```JavaScript:src.js
    return dt  + '/' + dt;
```
(ソース全体を見たい方はこちら: [src.js](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/3-basic/src.js))


最低限の機能をもつテストフレームワークを作ります。

```JavaScript:test.js
function execTests(TESTS) {
    const assert = function (actual, expected, title) {
        if (actual !== expected) {
            return "\n" +
                    '(' + title + ') actual   : ' + actual + "\n" +
                    '(' + title + ') expected : ' + expected + "\n";
        }
        return '';
    };
    for (var i = 0; i < TESTS.length; i++) {
        var ts_text  = TESTS[i][0];
        var ex_dates = TESTS[i][1];
        console.log(ts_text);
        var capturedUrl = '';
        var dates = bookmarkletToAddToGoogleCalendar(ts_text, function(url){ capturedUrl = url });
        var errorStr = '';
        errorStr += assert(dates, ex_dates, 'dates');
        if (errorStr) {
            console.error(errorStr);
        }
        console.log(capturedUrl);
        console.log('--------------------------------');
    }
}
```

テストケース実装します。

```JavaScript:test.html
var TESTS = [
    ['2016-01-02 09:01', '20160102T000100Z/20160102T000100Z'],
    ['2017/10/02 09:01', '20171002T000100Z/20171002T000100Z'],
    ['2017-10-02 09:01', '20171002T000100Z/20171002T000100Z'],
    ['2017.10.02 09:01', '20171002T000100Z/20171002T000100Z']
];
```
(ソース全体を見たい方はこちら: [test.html](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/3-basic/test.html))

これをブラウザで開いて F12 を押せばテスト結果が表示されます。
(テスト結果: [ここ](https://waterada.github.io/bookmarklet-google-calendar-add/qiita/3-basic/test.html) を開いて F12 を押してください。)



# 4. 文章の途中でも日付抽出できるように

正規表現を使って、選択範囲の中に含まれた日時を見つけ出せるようにします。
その際、（パターン増やしやすくするために）年月日時分をバラバラにとるようにして `new Date()` から脱却しておきます。
また、年は省略しても良いものとし、省略されたら現在の年が設定されるようにします。
さらに、選択範囲全体はカレンダーの「説明」にセットされるようにします。
まずは、テストケースを修正します:

```JavaScript:test.html
var TESTS = [
    ['日付指定: 2016-01-02 09:01 ★', '20160102T000100Z/20160102T000100Z'],
    ['日付指定: 2017/10/02 09:01 ★', '20171002T000100Z/20171002T000100Z'],
    ['日付指定: 2017-10-02 09:01 ★', '20171002T000100Z/20171002T000100Z'],
    ['日付指定: 2017.10.02 09:01 ★', '20171002T000100Z/20171002T000100Z'],

    ['年省略: 10/02 09:01 ★', '20161002T000100Z/20161002T000100Z'],
    ['年省略: 10.02 09:01 ★', '20161002T000100Z/20161002T000100Z'],
];
```
(ソース全体を見たい方はこちら: [test.html](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/4-regexp/test.html))
(テスト結果を見たい方はこちら: [ここ](https://waterada.github.io/bookmarklet-google-calendar-add/qiita/4-regexp/test.html) を開いて F12 を押してください。)


次はメインロジック。正規表現を複数パターン用意して、いずれかにマッチしたら日時を取り出すようにします。
ここでは `replace` メソッドを使います。
第２引数で（置換文字列の代わりに）コールバック関数を渡すとヒットした場合に処理を実行できるので、それを利用するのです。

```JavaScript:src.js
function bookmarkletToAddToGoogleCalendar(selected, open, NOW) {
    NOW = NOW || new Date();
    var dtReList = [
        /\b(?:(\d{4})\/)?(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})\b/,
        /\b(?:(\d{4})[.])?(\d{1,2})[.](\d{1,2})\s+(\d{1,2}):(\d{2})\b/,
        /\b(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})\b/,
    ];
    var dt = null;
    for (var j = 0; j < dtReList.length; j++) {
        if (dt) { return; }
        var selected2 = selected.replace(dtReList[j], function (a, y, m, d, h, i) {
            y = y || NOW.getFullYear();
            var str = y + '-' + m + '-' + d + ' ' + h + ':' + i;
            dt = new Date(str).toISOString().replace(/(:|-|\.\d+)/g, '');
            return '';
        });
        if (selected !== selected2) { break; }
    }
    var url = 'http://www.google.com/calendar/event?action=TEMPLATE&trp=false' +
        '&details=' + encodeURIComponent(selected) +
        '&dates=' + dt  + '/' + dt;
    open(url);
    return dt  + '/' + dt;
}
```
(ソース全体を見たい方はこちら: [src.js](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/4-regexp/src.js))

現在日時が引数で渡されていることにお気付きでしょうか。
こうすることで、テストしやすくしています。
本番ブックマークでは引数 `NOW` を省略して呼び、テスト時には `new Date('2016-01-01')` を固定で渡します。

```JavaScript:test.js
        var dates = bookmarkletToAddToGoogleCalendar(ts_text, function(url){ capturedUrl = url }, new Date('2016-01-01'));
```
(ソース全体を見たい方はこちら: [test.js](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/4-regexp/test.js))



# 5. 正規表現をメンテしやすくする

このままだと正規表現の管理が大変になりそうなので正規表現を簡単にするラッパー `RegExpSweet` を追加します。

```JavaScript:src.js
    function RegExpSweet() {
        this.replaces = [];
        var _this = this;
        this.__proto__.addSyntax = function (replacements) {
            Object.keys(replacements).forEach(function (search) {
                var replace = replacements[search];
                if (search.match(/^\w+$/)) { search = '\\b' + search + '\\b'; }
                search = new RegExp(search, 'g');
                _this.replaces.unshift({search: search, replace: replace});
            });
            return this;
        };
        this.__proto__.toRegExp = function(regExpStr, flags) {
            _this.replaces.forEach(function (a) {
                regExpStr = regExpStr.replace(a.search, a.replace);
            });
            return new RegExp(regExpStr, flags || 'g');
        }
    }
    var reSweetDate = new RegExpSweet();
    reSweetDate.addSyntax({' ': '\\s*'});
    reSweetDate.addSyntax({'  ': '\\s+'});
    reSweetDate.addSyntax({'<': '(?:', '>': ')?'});
    reSweetDate.addSyntax({
        D2: '\\d{1,2}',
        YYYY: '\\d{4}'
    });
```

このクラスを使うと、下記 `変更前` が `変更後` のように書けるようになります。

```JavaScript:変更前
    var dtReList = [
        /\b(?:(\d{4})\/)?(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})\b/,
        /\b(?:(\d{4})[.])?(\d{1,2})[.](\d{1,2})\s+(\d{1,2}):(\d{2})\b/,
        /\b(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})\b/,
    ];
```

```JavaScript:変更後
    var RE_DATES = [
        '<(YYYY)/>(D2)/(D2)  (D2):(D2)',
        '<(YYYY)[.]>(D2)[.](D2)  (D2):(D2)',
        '(YYYY)-(D2)-(D2)  (D2):(D2)'
    ];
    var dtReList = [];
    RE_DATES.forEach(function (pattern) {
        dtReList.push(reSweetDate.toRegExp('\\b' + pattern + '\\b'));
    });
```

ここは今後頻繁にメンテする部分ですので、こうしておけば、パッと見も見やすいですし、ケアレスミスも防げます。

(ソース全体を見たい方はこちら: [src.js](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/5-sweet/src.js))



# 6. 終日対応

googleカレンダーには時刻を設定しない、終日というものがありますので。これに対応します。
終日の場合、`dates` のフォーマットは `yyyymmdd/yyyymmdd` という書式になり、２番目の日付は翌日にしなければなりません（これで設定される見た目上は当日になります）。
下記のテストケースを追加:

```JavaScript:test.html
    ['日付指定: 2016-01-02 ★', '20160102/20160103'],
    ['日付指定: 2017/10/02 ★', '20171002/20171003'],
    ['日付指定: 2017-10-02 ★', '20171002/20171003'],
    ['日付指定: 2017.10.02 ★', '20171002/20171003']

    ['年省略: 10/02 ★', '20161002/20161003'],
    ['年省略: 10.02 ★', '20161002/20161003'],
```
(ソース全体を見たい方はこちら: [test.html](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/6-allday/test.html))
(テスト結果を見たい方はこちら: [ここ](https://waterada.github.io/bookmarklet-google-calendar-add/qiita/6-allday/test.html) を開いて F12 を押してください。)

正規表現の時刻部分を省略可能にします:

```JavaScript:src.js(変更)
    var RE_DATES = [
        '(YYYY/D2/D2)<(  D2:D2)>',
        '(YYYY[.]D2[.]D2)<(  D2:D2)>',
        '(YYYY-D2-D2)<(  D2:D2)>'
    ];
```

時刻がないなら２つ目の日付を翌日にするようメインロジックを変更します:

```JavaScript:src.js(変更)
    var zf = function (n) { return ('0' + n).slice(-2); };
    var dt1 = null, dt2 = null;
    for (var j = 0; j < dtReList.length; j++) {
        if (dt2) { return; }
        var selected2 = selected.replace(dtReList[j], function (a, y, m, d, h, i) {
            y = y || NOW.getFullYear();
            var str = y + '-' + m + '-' + d;
            if (h) { str += ' ' + h + ':' + i; }
            var dt = new Date(str);
            if (h) {
                dt1 = dt.toISOString().replace(/(:|-|\.\d+)/g, '');
                dt2 = dt1;
            } else {
                dt1 = dt.getFullYear() + zf(dt.getMonth() + 1) + zf(dt.getDate());
                dt = new Date(dt.getTime() + 24 * 3600 * 1000);
                dt2 = dt.getFullYear() + zf(dt.getMonth() + 1) + zf(dt.getDate());
            }
            return '';
        });
        if (selected !== selected2) { break; }
    }
```
(ソース全体を見たい方はこちら: [src.js](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/6-allday/src.js))



# 7. 日付範囲に対応

日付が２回登場したら範囲として扱うようにします。
下記のテストケースを追加します:

```JavaScript:test.html
    ['日付範囲: 2017/10/02 ～ 2017/11/03 ★', '20171002/20171104'],
    ['日付範囲: 2017/10/02 09:01 ～ 2017/10/02 10:02 ★', '20171002T000100Z/20171002T010200Z'],

    ['年省略: 10/02 ～ 11/03 ★', '20161002/20161104'],
    ['年省略: 10/02 09:01 ～ 10/02 10:02 ★', '20161002T000100Z/20161002T010200Z'],
    ['２つ目年省略→１つ目と同じ: 2018/10/02 ～ 10/02 ★', '20181002/20181003'],
    ['２つ目年省略→１つ目と同じ: 2009/01/02 09:01 to 03/04 10:02 ★', '20090102T000100Z/20090304T010200Z'],

    ['１つ目の日付不正→無視: 2017/10/99 ～ 2017/11/03 ★', '20171103/20171104'],
    ['１つ目の日付不正→無視: 2017/10/99 09:01 ～ 2017/10/02 10:02 ★', '20171002T010200Z/20171002T010200Z'],
    ['１つ目の時刻不正→時刻使わない: 2017/10/02 99:99 ～ 2017/11/03 10:02 ★', '20171002/20171104'],
    ['２つ目の時刻不正→時刻使わない: 2017/10/02 09:01 ～ 2017/11/03 99:99 ★', '20171002/20171104'],
    ['３つ目の日付→無視: 2017/10/02 ～ 2017/11/03 ～ 2017/12/04 ★', '20171002/20171104'],
```

ついでにイレギュラーケースも若干足しておきます:

```JavaScript:test.html
    ['日付指定: 2017/10/2 ★', '20171002/20171003'],

    ['日付類似: 2017.99.99 / 2017.10.02 ★', '20171002/20171003'],
    ['日付類似: 99/01 09:01 / 01.02 09:01 ★', '20160102T000100Z/20160102T000100Z'],

    ['時間のみ不正: 01/02 25:01 ★', '20160102/20160103'],
    ['時間のみ不正: 01/02 01:60 ★', '20160102/20160103'],

    ['空白区切り: 1/2 9:00★', '20160102T000000Z/20160102T000000Z'],
    ['空白区切り: 1/2  9:00★', '20160102T000000Z/20160102T000000Z'],

    ['日にちの後ろに時があっても時間と誤認しない: 1/29時間厳守★', '20160129/20160130'],

    ['年省略: 10/2 ★', '20161002/20161003'],
```
(ソース全体を見たい方はこちら: [test.html](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/7-range/test.html))
(テスト結果を見たい方はこちら: [ここ](https://waterada.github.io/bookmarklet-google-calendar-add/qiita/7-range/test.html) を開いて F12 を押してください。)

日付の処理も複雑になってきたので、ここらで思い切ってオブジェクトにします:

```JavaScript:src.js
    var SettingDates = function () {
        var dates = [];
        var zf = function (n) { return ('0' + n).slice(-2); };
        this.__proto__.pickupDate = function (y, m, d, h, i) {
            if (dates[1]) { return false; }
            y = y || (dates[0] && dates[0].args[0]) || NOW.getFullYear();
            var is2nd = !!dates[0];
            if (is2nd && !dates[0].hasHi) { h = i = null; } //前に時刻が無いなら今回も使わない
            //日付作成
            var str = y + '-' + m + '-' + d;
            if (h) { str += ' ' + h + ':' + i; }
            var dt = new Date(str);
            if (dt.toString() === 'Invalid Date') { dt = null; }
            var obj = {};
            if (h) {
                if (!dt) { //時間とっても成立か
                    return this.pickupDate(y, m, d);
                }
                obj.str = dt.toISOString().replace(/(:|-|\.\d+)/g, '');
            } else {
                if (!dt) { return false; }
                if (is2nd) { //日時の２つ目は翌日
                    dt = new Date(dt.getTime() + 24 * 3600 * 1000);
                }
                obj.str = dt.getFullYear() + zf(dt.getMonth() + 1) + zf(dt.getDate());
            }
            obj.hasHi = !!h;
            if (is2nd && !obj.hasHi && dates[0].hasHi) { //２つ目が時刻なしなのに１つ目が時刻ありなら１つ目を時刻なしに
                var prevArgs = dates[0].args;
                dates = [];
                this.pickupDate(prevArgs[0], prevArgs[1], prevArgs[2]);
            }
            obj.args = [y, m, d, h, i];
            dates.push(obj);
            return true;
        };
        this.__proto__.toString = function () {
            if (dates.length == 0) { return ''; }
            if (dates.length == 1) { this.pickupDate.apply(this, dates[0].args); }
            return dates[0].str + '/' + dates[1].str;
        }
    };
    var settingDates = new SettingDates();
    for (var j = 0; j < dtReList.length; j++) {
        var selected2 = selected.replace(dtReList[j], function (a, y, m, d, h, i) {
            var replaced = settingDates.pickupDate(y, m, d, h, i);
            return (replaced ? '' : a);
        });
        if (selected !== selected2) { break; }
    }
    var dates = settingDates.toString();
```
(ソース全体を見たい方はこちら: [src.js](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/7-range/src.js))



# 8. ここから、いろいろ拡張してみる

下記のような拡張を行いました:

- ES6 にする
- 全角文字
- ○年○月○日○時○分
- 和暦（平成、昭和）
- 日付と時刻の間の曜日は無視
- 英語月
- 年月日 時分～時分 のパターン
- 半＝30分
- ○曜日の8時(=次の○曜日の8時と認識)
- テスト見やすく

ソースを大幅に書き換えました:
[src.js](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/8-others/src.js)
[test.html](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/8-others/test.html)
[test.js](https://github.com/waterada/bookmarklet-google-calendar-add/tree/gh-pages/qiita/8-others/test.js)
[テスト結果](https://waterada.github.io/bookmarklet-google-calendar-add/qiita/8-others/test.html) を開いて F12 を押してください。)

テストがあることで非常に拡張＆リファクタリングはやりやすいです。
うっかり見落としていた仕様にテストが落ちて教えてくれるので。
ただ、テスト落ちたときの原因究明に若干時間かかるのが気になりました。
まだ、疎結合がうまくいってないような感覚があります。

一旦今回の記事はここまでにして、次回はこれをテスト駆動で書き直してみようと思います。
今以上の疎結合が見えてくるのではないかと思いまして。