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=
とほぼ同等ですが、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/
でアクセスできるようになります。
メインのソース
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 にしています。
途中ですが
一旦ここまででアップしてみます。
書きながら説明しづらいなーっと感じたところは適宜、リファクタしながら進めました。
その際も単体テストがあるのでリファクタが非常に楽です。単体テストで楽になるのを知らない人はぜひ知ってもらいたいところですね。
反響があれば、次回は後半部分と、単体テストの説明を書こうと思います。