LoginSignup
9

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-09-27

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

概要

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

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

使ってるテクニック:

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

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

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

ただのブックマークと違って、URLの代わりに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= とほぼ同等ですが、varfunction(){ ~ } のスコープであるのに対し、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/ でアクセスできるようになります。

メインのソース

function bookmarkletToAddToGoogleCalendar(selected, open, NOW) {

}
  • 【bookmarklet】名前空間をできるだけ汚さないように外側を関数で囲んであります。
  • 【単体テスト】また、テストしやすいように外部依存のあるもの(選択文字列、ポップアップメソッド、現在時刻)は引数で受け取っています。
    NOW = NOW || new Date();
  • 【JavaScript】第3引数 NOW が省略されたら(普通は省略)、現在時刻を使うようにします。現在時刻を外から渡せないと単体テストがやりづらくなるので、このようにします。
    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_)のみで構成されていたらという意味です。
    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>',
        '()()()()',
    ];
  • 【説明】日の書き方と、時の書き方のパターンを抜き出して定義しています。あとですべての組み合わせを作ってあとで総当りチェックさせます。
  • 【説明】一番下の ()()()() は、時刻が存在しない場合のパターンです。後の処理でn番目のカッコの中身という具合に取得するので () の数を同じにする必要があるのでこのようにしています。
  • 【ES6】const は定数を定義します。let と同様 { ~ } のスコープです。違いは書き換えられません。ただし、定数そのものが書き換えられないだけでプロパティや配列の要素は書き換え可能です。
    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);
        }
    }
  • 【テクニック】日と時、4つずつあるパターンの総当りを作っています。16パターンの正規表現が作られます。パターンが増えると掛け算で総数が増えることになりますが、ブックマークレットという使用方法なら、微々たるパフォーマンスよりもメンテナンス性が優先されるのでこのようにしています。選択範囲はそれほど大きくもならないので、パフォーマンス犠牲といっても判らないほどでしょう。
    const zf = n => ('0' + n).slice(-2);
  • 【JavaScript】('0' + n).slice(-2) は2桁でゼロ埋めする一般的な手法です。
  • 【ES6】const zf = n => ~ ; はラムダ式で関数 zf を定義しています。const zf = function (n) { return ~; }; と書くのとほぼ同じです。ラムダ式はロジックを function などの長い文字列なしに端的に表現できます。文法のための文字が減ることで、ロジックが見やすくなります。さきほど「ほぼ」同じと言ったのは this が置き換わらない点と、var のスコープ対象にならない点が異なるためです。
    const analyzeYmd = (is2nd, y, m, d) => {
        let dt = new Date(`${y}-${m}-${d}`);
        if (isNaN(dt)) { return; }
        if (is2nd) { //日時の2つ目は翌日
            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 という文字列で日付範囲を指定することになります。そのとき、2つ目の日付は翌日にする必要があります。つまり 2000/1/1~2000/1/2 なら 20000101/20000103 となります。
  • 【JavaScript】dt = new Date(dt.getTime() + 24 * 3600 * 1000) は翌日にする一般的な方法です。
    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のタイムゾーンで指定する必要があります。2つ目の日付は特に翌日にする必要はありません。2000/1/1 09:00~2000/1/1 10:00 なら 20000101T000000Z/20000101T010000Z となります。
  • 【テクニック】dt.toISOString() は GMT のタイムゾーンで yyyy-mm-ddThh:ii:ss.mmmZ という文字列を返しますので、ここから .replace(/(:|-|\.\d+)/g, '') で余分な文字を撤去して、欲しい文字列を作っています。これが一番簡単かなと思いました。
    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() の第2引数で置き換え文字列の代わりに渡します。関数の中では1つ目にヒットした日時は date1 に、2つ目は date2 にセットし、3つ目以降は無視します。
  • 【テクニック?】最初は 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)!! という演算子が出てきて驚いたかもしれません。これは ! を2回行うことで (date1 ? true : false) と同じ意味にしています。つまり 明示的に boolean にしています。

途中ですが

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

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
9