1. 入力内容をテンプレとして保存する
- ChEx.matchUrl() … URLがマッチした場合のみ処理を行う
/** リンクが一致したらcallbackを実行。* は [\w%+.-]+ として扱われ、(*) は callback の引数に中味が渡る。** とすると .+? として扱われる */
ChEx.matchUrl = function(condition, callback, _href) {
let url = _href || location.href;
condition = condition.replace(/([.?+\/])/g, '\\$1').replace(/\*\*/g, '.+?').replace(/\*/g, '[\\w%+.-]+');
condition = new RegExp(condition);
url.replace(condition, (hit, a, b, c, d) => {
callback(a, b, c, d);
});
};
//使用例
// 商品登録ページのみ
ChEx.matchLink('/projects/*/items/add', function() { /* 拡張処理 */ });
// 商品詳細ページのみ
ChEx.matchLink('/projects/*/items/(*)/detail', function(itemId) { /* 拡張処理 */ });
//ちなみに同等のことを正規表現でかくとこうなる(正規表現だと読みづらい)
if (location.href.match(/\/projects\/.*?\/items\/add/)) { /* 拡張処理 */ }
let matches = location.href.match(/\/projects\/.*\/items\/(.*?)\/detail/);
if (matches) { let itemId = matches[1]; /* 拡張処理 */ }
- ChEx.templateStorage() … 入力内容を簡単にテンプレ化する関数
/**
* 入力内容をテンプレートとして保存する機能
* @param opt.storageKey
* @param opt.init - 保存/選択するためのダイアログを開くボタン($opener)の表示を行うコールバック関数。例: $opener => $opener.appendTo('form')
* @param opt.title
* @param opt.inputs
* @param opt.onAdd
* @param opt.onApply
*/
ChEx.templateStorage = function (opt) {
const dialog = ChEx.dialog({
title: opt.title,
makeBody: () => {
let $table;
let $root = $('<div></div>').append(
$table = $('<table></table>'),
$('<input type="button" value="Add Template" style="margin-top: 5px;">').click(() => {
let title = prompt('title');
if (title) {
let values = opt.inputs.map(selector => {
let $input = $(selector);
let type = $input.attr('type');
if (type === 'checkbox' || type === 'radio') {
return $input.filter(':checked').get().map(elem => elem.value).join(",");
} else if ($input.is('select') && $input.prop('multiple')) {
//noinspection JSValidateTypes
return $input.children('option:selected').get().map(elem => elem.value).join(",");
} else {
return $input.val();
}
});
if (opt.onAdd) opt.onAdd(values);
let tsv = `${title}\t${values.join("\t")}`;
__addRow(tsv);
ChEx.storage.saveLocal(opt.storageKey, [], templates => {
templates.push(tsv);
});
}
})
);
const __addRow = function (tsv) {
let $tr;
$table.append(
$tr = $('<tr></tr>').append(
$('<td style="padding:0;"><input type="button" value="Select" style="background-color: lightblue;"></td>').click(() => {
dialog.close();
let values = tsv.split("\t");
values.shift(); //タイトル撤去
for (let selector of opt.inputs) {
let val = values.shift();
let $input = $(selector);
let type = $input.attr('type');
if (type === 'checkbox' || type === 'radio') {
$input.prop('checked', false);
val.split(',').forEach(v => $input.filter(`[value="${v}"]`).prop('checked', true));
} else if ($input.is('select') && $input.prop('multiple')) {
//noinspection JSValidateTypes
$input.children('option').prop('selected', false);
//noinspection JSValidateTypes
val.split(',').forEach(v => $input.children(`option[value="${v}"]`).prop('selected', true));
} else {
$input.val(val);
}
}
if (opt.onApply) opt.onApply(values); //余った values を渡す
}),
$('<td style="padding: 0 4px;"></td>').text(tsv.replace(/\t[\s\S]*$/, '')).attr('title', tsv),
$('<td><input type="button" value="Delete"></td>').click(() => {
if (!confirm(`Delete?\n\n${tsv.replace(/\n/g, ' ').replace(/\t/g, "\n")}`)) return;
$tr.remove();
ChEx.storage.saveLocal(opt.storageKey, [], function (templates) {
let idx = templates.indexOf(tsv);
if (idx === -1) return ChEx.error('No tsv in the template. tsv: ' + tsv);
templates.splice(idx, 1);
});
}),
)
);
};
ChEx.storage.loadLocal(opt.storageKey, [], templates => {
for (let tsv of templates) {
__addRow(tsv);
}
});
return $root;
}
});
opt.init($(`<input type="button" value="${opt.title}">`).click(() => dialog.open()));
};
- ChEx.dialog() … jQuery UI は大きすぎるので使わずにダイアログ出す
//---------------------------------
//使用例
let dialog = ChEx.dialog({
makeBody: () => $('<div>ダイアログの中身</div>'),
title: 'ダイアログのタイトル'
});
//デフォルトでは Cancel ボタンしかないので追加する
dialog.addButton('OK', () => {
dialog.close(); //ダイアログ閉じる
//OK押した場合の特別な処理
}, 'lightblue');
dialog.open(); //ダイアログを開く
//---------------------------------
/** 最小実装のダイアログ(jquery-uiは大きすぎるので) */
ChEx.__dialog_opened = false;
/**
* @param opt.makeBody - ダイアログの中身を作るコールバック。戻り値で中身を返す
* @param opt.css
* @param opt.title
* @param opt.onOpen
* @param opt.onClose
*/
ChEx.dialog = function (opt) {
if (ChEx.__dialog_opened) return;
ChEx.__dialog_opened = true;
let $dialog, $back, $message;
const dialog = {
open: () => {
$('body').append(
$dialog = $('<div></div>').css(Object.assign({
position: 'absolute',
height: 'auto',
width: '600px',
top: 0,
left: 0,
zIndex: 2147483647,
borderRadius: '3px',
border: '1px solid #ddd',
background: '#fff',
color: '#333',
fontFamily: 'Arial,Helvetica,sans-serif',
fontSize: '1em',
overflow: 'hidden',
padding: '.2em',
outline: 0,
}, opt.css)).append(
(opt.title ? $('<div></div>').text(opt.title).css({
padding: '.4em 1em',
borderRadius: '3px',
border: '1px solid #ddd',
background: '#e9e9e9',
color: '#333',
fontWeight: 'bold',
}) : null),
$('<div></div>').css({
padding: '.5em 1em',
color: '#333',
}).append(
$message = opt.makeBody(),
$('<div class="chex-dialog-buttons" style="margin-top: 10px;"></div>')
)
),
$back = $('<div></div>').css({
zIndex: 2147483646,
background: '#aaa',
opacity: '.3',
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
})
);
if (opt.onOpen) opt.onOpen();
$message.scrollTop(0); //スクロールが出る場合、ボタン(末尾)にフォーカスが行ってしまうので、これで先頭に戻す
let $window = $(window);
let ww = $window.width();
let dw = $dialog.width();
//noinspection JSValidateTypes
$dialog.css({
left: (ww - dw) / 2,
top: $window.scrollTop() + 20 //Math.max(20, (wh - dh) / 2),
});
$(document).on('keydown.chex-dialog', function (e) {
if (e.which === 27) dialog.close();
});
dialog.addButton('Cancel', () => dialog.close());
},
close: () => {
$dialog.remove();
$back.remove();
$(document).off('.chex-dialog');
if (opt.onClose) opt.onClose();
ChEx.__dialog_opened = false;
},
addButton: (label, onClick, bgColor) => {
$dialog.find('.chex-dialog-buttons').append(
$('<button></button>').text(label).click(onClick).css({
border: '1px solid #c5c5c5',
borderRadius: '3px',
padding: '.4em 1em',
color: '#454545',
background: bgColor || '#f6f6f6',
fontSize: '1em',
cursor: 'pointer',
marginRight: '10px',
})
);
},
};
return dialog;
};
- ChEx.storage … Storage保存を簡単に
//---------------------------------
//使用例
ChEx.storage.saveLocal('example.xyz', {}, data => {
data['abc'] = 'def';
});
ChEx.storage.loadLocal('example.xyz', {}, data => {
console.log(data['abc']);
});
//---------------------------------
/** ローカルストレージ */
ChEx.storage = {};
ChEx.storage.loadLocal = function (key, defaultValue, callback) {
if ((typeof defaultValue) === 'function') return ChEx.error('defaultValue is required.');
//noinspection JSUnresolvedVariable
const __local = chrome.storage.local;
__local.get(key, (data) => {
if (!data[key]) {
data[key] = defaultValue;
}
callback(data[key]);
});
};
ChEx.storage.__saveLocal = function (base, afterSave) {
//noinspection JSUnresolvedVariable
const __local = chrome.storage.local;
if (afterSave) {
__local.set(base, afterSave);
} else {
__local.set(base);
}
};
ChEx.storage.saveLocal = function (key, defaultValue, callback, afterSave) {
if ((typeof defaultValue) === 'function') return ChEx.error('defaultValue is required.');
//noinspection JSUnresolvedVariable
const __local = chrome.storage.local;
__local.get(key, (base) => {
if (!base[key]) {
base[key] = defaultValue;
}
let save = callback(base[key]);
if (save !== false) {
ChEx.storage.__saveLocal(base, afterSave);
} else {
if (afterSave) afterSave();
}
});
};
// content_script.js
$(function () {
ChEx.matchUrl('/expense/', function() {
ChEx.templateStorage({
storageKey: 'example01.templates', //Storageのキー
init: $opener => $opener.appendTo('form'), //「テンプレートから選択」ボタンの配置
title: 'テンプレートから選択', //表示するボタンのタイトル
inputs: [ //保存するinputを指すselector
'[name="a"]',
'[name="b"]',
'[name="c"]',
'[name="d"]',
'[name="e"]',
'[name="f"]',
],
});
});
});
2. 別ページで算出した結果をもとに転記支援
| 1月 | 2月 | 3月 |
商品A | 0 | 20 | 30 |
商品B | 10 | 30 | 0 |
商品C | 0 | -10 | 80 |
別画面の入力欄
- セルの値を合計する
- 別ページへの表示
- ChEx.waitDom() … Domが出現するまで待ってから処理を行う。
//---------------------------------
//使用例
// form .target を最大 10 秒探して、見つかれば処理を行う。見つからなければエラーにする。
ChEx.waitDom('form .target', '対象のDom', 10, function($target) {
// $target には取得することができた Dom (jQuery) が入ってる。
// 第2引数の文言はエラーメッセージ用。
// Dom が見つからない場合、Dom から意図がつかめなくなり得るので、
// かならず日本語でも残しておくべき。
});
//---------------------------------
/** DOMの出現を待つ。1秒ずつmax回試行。見つかったらnextを実行 */
ChEx.waitDom = function(selector, label, max, next, _count) {
let $item = $(selector); //毎回取り直す必要あり
if ($item.length) {
next($item);
} else {
_count = (_count || 0) + 1;
if (_count < max) {
setTimeout(function () {
ChEx.waitDom(selector, label, max, next, _count);
}, 1000);
} else {
ChEx.error(`${max} 秒待ちましたが ${selector} (${label}) が見つかりませんでした。`);
}
}
};
// content_script.js
$(function() {
ChEx.matchUrl('/stocks/', function() {
//合計列を追加し、同時にstorageに保存する
ChEx.waitDom('#stock table', '合計するテーブル', 10, function($table) {
let sums = {};
$table.find('tr').each((i, tr) => {
let $tr = $(tr);
let key = $tr.data('name');
if (key) {
let sum = 0;
$tr.find('td').each((i, td) => sum += parseInt($(td).text()));
sums[key] = sum;
$tr.append(`<th style="background-color: lightyellow;">${sum}</th>`);
} else {
$tr.append(`<th style="background-color: lightyellow;">合計</th>`);
}
});
ChEx.storage.saveLocalDirectly('example02.sums', sums);
});
});
ChEx.matchUrl('/inputs/', function() {
//入力画面が表示されたらstorageからデータを読み出し転記しやすいリンクを出す
ChEx.storage.loadLocal('example02.sums', {}, sums => {
for (let key of Object.keys(sums)) {
let $input = $(`input[name="${key}"]`);
$input.after(
$('<a href="javascript:void(0)"></a>').text(`<< ${sums[key]}`).click(() => {
$input.val(sums[key]);
})
);
}
});
});
});
3. 絞り込み
↓クリックで選択可能
0 | 【前夜祭】大人のビルコン 〜撤退技術スペシャル〜(1) | lestrrat |
1 | 【前夜祭】大人のビルコン 〜撤退技術スペシャル〜(2) | lestrrat |
2 | 【前夜祭】大人のビルコン 〜撤退技術スペシャル〜(3) | lestrrat |
3 | 【前夜祭】大人のビルコン 〜撤退技術スペシャル〜(4) | lestrrat |
4 | Opening | lestrrat |
5 | Desktop Apps with JavaScript | felixrieseberg |
6 | オンプレ、クラウドを組み合わせて作るビックデータ基盤 | nii_yan |
7 | DeepLearningによるアイドル顔識別を支える技術 | sugyan |
8 | 初めてのMySQLチューニング -5.7 対応版- | mamy1326 |
9 | PHPで支える大規模アーキテクチャ | ytake |
10 | OSS開発を仕事にする技術 | mumoshu |
11 | ランチセッション A | lestrrat |
12 | ランチセッションB | lestrrat |
13 | 真のコンポーネント粒度を求めて | Takazudo |
14 | 横山三国志に「うむ」は何コマある? | heruheru3 |
15 | Androidアプリ開発アンチパターン | fkmhrk |
16 | ブラウザ拡張のクロスブラウザ対応についてどう向き合っているか | pastak |
17 | マイクロチームでの高速な新規開発を支える開発・分析基盤 | timakin |
18 | 複雑なJavaScriptアプリケーションに立ち向かう... | Shinpeim |
19 | Anatomy of DDoS | suzannealdrich |
20 | Haskellを使おう | hiratara |
21 | フレームなき道を拓くPHP | zonuexe |
22 | Goで実装する軽量マークアップ言語パーサー | aereal |
23 | RDBアンチパターン リファクタリング | soudai |
24 | Building high performance... | cubicdaiya |
25 | Kubernetesのクラスタ1つに開発と本番運用に必... | Kenichi Naoe |
26 | ディープラーニングを加速するVolta GPUプラットフォーム | Mana Murakami |
27 | Lightning Talks | lestrrat |
28 | 懇親会 | lestrrat |
29 | 3Dプリンタで作る1次元セル・オートマトン、階差機関、... | mackee |
30 | ここまで出来るmruby | pyama86 |
31 | 知られざる世界 〜WEB以外のPHP〜 | uzulla |
32 | Chrome拡張を使って様々なWebサービスをハックする | Wataru Terada |
33 | 静的解析とUIの自動生成を駆使してモバイルアプリの運用... | tenntenn |
34 | Googleが開発したニューラルネット専用LSI「Te... | kazunori279 |
35 | 小さく始めて育てるコンパイラ | rhysd |
36 | 今日から使えるCSS Grid | geckotang |
37 | サーバサイドKotlinのすすめ | Noriyuki Ishida |
38 | Ionic... | rdlabo |
39 | ランチセッション C | lestrrat |
40 | ランチセッション D | lestrrat |
41 | AWS CodeBuild... | ssig33 |
42 | polyglot になろう !! | januswel |
43 | Make you a React: How to... | jbucaran |
44 | OSSで始めるセキュリティログ収集 | bungoume |
45 | OSS の引き継ぎ方 | hsbt |
46 | Factory Class | obra |
47 | HADOが試したポジショントラッキングの話 | izugch4423 |
48 | フロントエンドエンジニアが主役のBaaSを作った話 | Takezaki Shinichiro |
49 | OSS貢献超入門 | shigemk2 |
50 | WEB+DB PRESS 100号記念 特別企画(仮) | lestrrat |
51 | Writing PHP at Slack HQ | carlyhasredhair |
52 | 汎用CMSから新規開発の自社サービスへ移行した事例のご紹介 | motchang |
53 | 型を意識した PHP アプリケーション開発 | shin1x1 |
54 | ここが辛いよサーバーレス。だが私は乗り越えた | yamitzky |
55 | Serverless Server Side Swift | noppoMan |
56 | Closing | lestrrat |
57 | MP4 ParserをSwiftで書いてみた | Satoshi Shmd |
58 | 分割QRコードの読み取り方 | Satoshi Shmd |
59 | SketchでVueコンポーネントを設計してみる | Yosuke Doke |
60 | 電池一本で5年間動くデバイスを作るには。 | Takashi Komori |
- ChEx.keyDownSearch() … キー押すことで絞り込ませることで探しやすくする。
/**
* 文字入力により行が絞り込まれていく
* @param {function} opt.init - 入力内容を表示するエリア($input)を配置するコールバック。$input => {} の形。
* @param {jQuery} opt.$foundRows - 検索結果で表示/非表示をする行
*/
ChEx.keyDownSearch = function (opt) {
let $input = $('<span style="margin-left: 10px; font-weight: bold; font-size: medium;"></span>');
opt.init($input);
$('body').keydown(function (e) {
let input = $input.text();
if (e.which === 8) { //BS
input = input.substr(0, input.length - 1); //末尾1文字撤去
} else {
let char = String.fromCharCode(e.which);
if (char.match(/[A-Z0-9]/)) input += char; //特定の文字のみ許す
}
$input.text(input);
opt.$foundRows.show();
if (input) {
opt.$foundRows.each(function () {
let $row = $(this);
if ($row.text().toUpperCase().indexOf(input) === -1) $row.hide();
});
}
});
};
- キー入力にあわせて絞り込む
- ChEx.findOne() … $() の代わり。構造変わった場合に検知しやすい
/** きっかり1個ヒットしない場合にエラー出す。
* selector だけだと動かなくなった際に何が欲しかったのかわからなくなるので label も書くこと */
ChEx.findOne = function (selector, label) {
let $item = $(selector);
if ($item.length !== 1) {
ChEx.error(`${selector} (${label}) が ${$item.size()} 個あります。`);
}
return $item;
};
- ChEx.findOneThen() … $() の代わり。構造変わった場合に検知しやすい
/** findOne(selector) 後、`${selector} ${subSelector}` で再検索する */
ChEx.findOneThen = function (selector, subSelector, label) {
ChEx.findOne(selector, `${label} のTop`);
return $(`${selector} ${subSelector}`);
};
// content_script.js
$(function () {
ChEx.matchUrl('/sessions/', function() {
ChEx.keyDownSearch({
init: $input => {
ChEx.findOne('.main', '絞り込みの枠').prepend(
$('<div style="padding: 5px; background-color: darkblue; color: white;">文字をタイプすることで絞り込みが可能です: </div>').append(
$input.css({ marginLeft: '10px', fontWeight: 'bold', fontSize: 'medium' })
)
);
},
$foundRows: ChEx.findOneThen('#aaa', 'table > tbody > tr', '絞り込み単位となる行')
});
});
});
5. redmine の担当者を選択しやすくする
コメント欄:
- 画面に表示されている特定の情報を取得
- 選択するリンク追加
// content_script.js
$(function () {
ChEx.matchUrl('/tickets/*/edit', function() {
let users = $("a[href^=\"/users/\"]").get().map(a => $(a).text().trim());
users = ChEx.uniq(users);
users = users.sort((a, b) => (a.toUpperCase() > b.toUpperCase()));
$('.user-select').after(
$('<div></div>').append(
users.map(name => ChEx.clickableLink(`[${name}]`).css({
marginLeft: '8px',
fontSize: 'small',
}).click(() => $(`.user-select option:contains(${name})`).prop('selected', true)))
)
);
});
});
6. 日時を日本時間に書き換え、現在時との差分を色で示す
本日: 2017-07-05
1 | abcdef | Sun Jul 03 2017 09:01:06 UTC |
2 | ghijkl | Sun Jul 08 2017 09:01:06 UTC |
3 | mnopqr | Sun Jul 04 2017 09:01:06 UTC |
4 | stuvwx | Sun Jul 09 2017 09:01:06 UTC |
5 | yzabcd | Sun Jul 01 2017 09:01:06 UTC |
6 | efghij | Sun Jul 10 2017 09:01:06 UTC |
7 | klmnop | Sun Jul 02 2017 09:01:06 UTC |
8 | qrstuv | Sun Jul 05 2017 09:01:06 UTC |
9 | wxyxab | Sun Jul 06 2017 09:01:06 UTC |
0 | cdefgh | Sun Jul 07 2017 09:01:06 UTC |
- ChEx.dateColor() … 日付を日本時間にしてフォーマットして色付けする
/**
* @param {string} opt.selector - 日付が入ってる欄を指すselector
* @param {string} opt.format - (省略可)日付の書式
* @param {bool} opt.color - (省略可)色付けするならtrue
* @param {int} opt.now - (省略可)現在のtime値
*/
ChEx.dateColor = function (opt) {
let $dts = $(opt.selector);
//日付欄ごとにループ
$dts.each((i, dt) => {
let $dt = $(dt);
let date = new Date($dt.text());
//日付をフォーマットして入れ直す
$dt.text(ChEx.dateFormat(date, opt.format || 'Y-M-D H:I:S'));
//現在との差分を計算
if (opt.color) {
let dateNum = date.getTime();
if (dateNum <= opt.now) { //期限過ぎたらだんだん赤黒く
let num = Math.max(32, 255 - parseInt((opt.now - dateNum) / 1000 / 3600 / 24 * 32));
$dt.css('background', `rgb(${num}, 0, 0)`);
} else { //期限がせまったら黄色く
let num = Math.min(255, parseInt((dateNum - opt.now) / 1000 / 3600 / 24 * 32));
$dt.css('background', `rgb(255, 255, ${num})`);
}
}
});
};
- ChEx.sortDom() … Domを並べ替える。
//$().sort()は少し癖があるのでそれを吸収する
ChEx.sortDom = function (opt) {
let $rows = $(opt.selector);
$rows.sort((a, b) => opt.getValue($(a)) > opt.getValue($(b)));
$rows.parent().append($rows);
};
- ChEx.dateFormat() … Date値をフォーマット。
/** 日付フォーマット。'Y-M-D H:I:S.MS' の形式で指定 */
ChEx.dateFormat = function (date, format) {
let str = format;
str = str.replace(/MS/g, () => `00${date.getMilliseconds()}`.slice(-3));
str = str.replace(/Y/g, () => date.getFullYear());
str = str.replace(/M/g, () => `0${date.getMonth() + 1}`.slice(-2));
str = str.replace(/D/g, () => `0${date.getDate()}`.slice(-2));
str = str.replace(/H/g, () => `0${date.getHours()}`.slice(-2));
str = str.replace(/I/g, () => `0${date.getMinutes()}`.slice(-2));
str = str.replace(/S/g, () => `0${date.getSeconds()}`.slice(-2));
return str;
};
// content_script.js
$(function () {
ChEx.matchUrl('/tickets/', function() {
ChEx.dateColor({
selector: '.exp-dt',
format: 'Y-M-D H:I:S',
color: true,
});
//ソート
$('.data-table').after(
ChEx.clickableLink('Sort').click(function () {
ChEx.sortDom({
selector: '#aaa tr',
getValue: $tr => new Date($tr.find('.exp-dt').text()).getTime(),
});
})
);
});
});
7. 特定の領域を書き換えられるように
- ChEx.rewritableTexts() … 画面を書き換えられるように
/**
* @param {jQuery} opt.$targets - 書き換え可能にする対象
* @param {string} opt.storageKey - 保存につかうキー
* @param {function} opt.getId - 保存につかうID
* @param {function|undefined} opt.rewritableIf - (省略可) 書き換え可能にする条件
* @param {function|undefined} opt.onChange - (省略可) 書き換えた際に見た目など変えたい場合
*/
ChEx.rewritableTexts = function (opt) {
//設定済みコメント読み込み
ChEx.storage.loadLocal(opt.storageKey, {}, texts => {
opt.$targets.each((i, target) => {
let $target = $(target);
let id = opt.getId($target);
let orig = $target.text().trim();
$target.attr('data-chrome-ex-orig', orig);
if (texts[id]) {
$target.text(texts[id]);
if (opt.onChange) opt.onChange($target, orig !== texts[id]);
}
});
});
//クリックイベント設定
opt.$targets.click(event => {
let $target = $(event.currentTarget);
if (opt.rewritableIf && !opt.rewritableIf($target)) { return; }
//確認
let text = prompt("", $target.text().trim());
if (text === null) {
return;
}
//入力値に書き換え or 空欄なら元の値に戻す
if (text) {
$target.text(text);
if (opt.onChange) opt.onChange($target, true);
} else {
let orig = $target.attr('data-chrome-ex-orig');
$target.text(orig);
if (opt.onChange) opt.onChange($target, false);
}
//保存
let id = opt.getId($target);
if (text) {
ChEx.storage.saveLocal(opt.storageKey, {}, function (texts) {
texts[id] = text;
});
} else {
ChEx.storage.saveLocal(opt.storageKey, {}, function (texts) {
delete texts[id];
});
}
});
};
// content_script.js
$(function () {
ChEx.matchUrl('/expense/', function() {
ChEx.rewritableTexts({
storageKey: 'example07.comments',
$targets: $('#aaa .chex-text'),
getId: $target => $target.prev('.chex-id').text(),
onChange: ($target, changed) => $target.css('color', changed ? 'red' : 'black'),
});
});
});
8. 利用者に通知を送る
- ChEx.info() … 拡張名、問い合わせ先などを登録する
/** 通知用基本情報の登録 */
ChEx.__info = {};
ChEx.info = function (pluginName, email, author) {
ChEx.__info = {pluginName, email, author};
};
- ChEx.notify() … 変更内容などをユーザに通知する
ChEx.notify = function (storageKey, list) {
ChEx.storage.loadLocal(storageKey, {}, data => {
let already = data['alreadyReadNotify'] || '';
list = list.sort((a, b) => a.ymd > b.ymd);
if (already) {
//既読は除去
list = list.filter(item => already < item.ymd);
}
if (list.length) {
let later = data['alreadyReadNotifyLater'] || '';
if (later && (new Date()).getTime() < later) return;
//表示
$('body').append(
$('<div id="chex-notify" style="position: fixed; left: 0; top: 0; padding: 0; width: 100vw; background-color: lightblue; z-index: 9999; box-shadow: 4px 4px 4px rgba(0,0,0,0.2);"></div>').append(
`<div style="padding: 10px; background-color: darkblue; color: white;">${ChEx.h(ChEx.__info.pluginName)}</div>`,
list.map(item => `<div style="margin: 20px; font-size: medium;"><div style="color: gray;">${item.ymd}</div><ul><li>${item.htmls.join('<li>')}</ul></div>`),
$('<div style="display: flex; justify-content: center; align-items: center; margin: 30px"></div>').append(
$('<input type="button" value="Close (あとで再通知)" style="font-size: larger; padding: 10px;">').click(() => {
$('#chex-notify').hide();
ChEx.storage.saveLocal(storageKey, {}, data => {
data['alreadyReadNotifyLater'] = (new Date()).getTime() + 10 * 60 * 1000; //10分後
});
}),
$('<input type="button" value="Close (完了)" style="margin-left: 60px; font-size: larger; padding: 10px; background-color: darkblue; color: white;">').click(() => {
$('#chex-notify').hide();
ChEx.storage.saveLocal(storageKey, {}, data => {
data['alreadyReadNotify'] = list[list.length - 1].ymd;
});
}),
`<div style="margin-left: 60px; font-size: larger;">連絡先: ${ChEx.inquiryLink()}</div>`
)
)
);
}
});
};
- 既読処理をどうやってるのか
// content_script.js
$(function () {
ChEx.info('●●の改造', 'wataru.terada@accenture.com', '寺田 渉');
ChEx.notify('example08.notify', [
{ ymd: '2017-06-17 (v1.22)', htmls: ['通知する機能を追加しました。今後、新機能を追加した際にはこの機能でお知らせします。',
'【重要】Timeレポートのプルダウンで絞り込みできるようにしました。キー入力することでリストが絞り込まれます。BSキーで絞り込み文字を削除できます。',
'【重要】Taxi のテンプレート保存機能は作ったものの、いまいち便利さが判らないわりにメンテが大変なので削除することにしました。もし困るという人がいましたらすぐに連絡ください。']},
{ ymd: '2017-06-26 (v1.23)', htmls: ['【バグFIX】新規作成ページの場合に時間転記のリンクが出なかったので修正しました。']},
]);
});
9. Storage の内容をメンテしやすく
- ope-storage.js … Storageを管理する画面を簡単に作るためのもの
$(function () {
$('.ope-storage-object,.ope-storage-array').each(function () {
let $div = $(this);
let STORAGE_KEY = $div.attr('data-storage');
let IS_ARRAY_TYPE = $div.is('.ope-storage-array');
let TITLE = $div.attr('data-title');
let COL_RATE = $div.attr('data-col-rate');
let isJson = location.href.match(/json=(1|true)/);
$div.css({ margin: '2px', flex: COL_RATE, marginBottom: '20px' });
$div.append(
`<div style="height: 30px; display: flex; align-items: flex-end;">${TITLE || STORAGE_KEY}</div>`,
`<textarea style="width: 100%; height: calc(100% - 40px);"></textarea>`
);
let $textarea = $div.find('textarea');
if (isJson) {
ChEx.storage.loadLocal(STORAGE_KEY, null, data => {
if (data !== null) {
let text = JSON.stringify(data);
$textarea.val(text);
}
});
} else if (IS_ARRAY_TYPE) {
ChEx.storage.loadLocal(STORAGE_KEY, [], data => {
if (!$.isArray(data)) throw new Error(`${STORAGE_KEY} が ope-storage-array で参照されましたが、これは配列ではありません。`);
data = data.map(v => v.replace(/\n/g, '\\n'));
let text = data.join("\n");
$textarea.val(text);
});
} else {
ChEx.storage.loadLocal(STORAGE_KEY, {}, data => {
if ($.isArray(data)) throw new Error(`${STORAGE_KEY} が ope-storage-array で参照されていませんが、これは配列です。`);
let text = Object.keys(data).sort().map(key => `${key}: ${data[key]}`.replace(/\n/g, '\\n')).join("\n");
$textarea.val(text);
});
}
//noinspection JSUnresolvedFunction
$div.find('textarea').change(function () {
if (!confirm('データが変更されました。保存しますか?')) {
location.reload(); //元に戻す
return;
}
if (isJson) {
let data = $textarea.val();
if (data) {
data = JSON.parse(data);
ChEx.storage.saveLocalDirectly(STORAGE_KEY, data, () => location.reload());
} else {
ChEx.storage.removeLocal(STORAGE_KEY, () => location.reload());
}
} else if (IS_ARRAY_TYPE) {
let data = $textarea.val().trim().split("\n").map(v => v.trim().replace(/\\n/g, "\n"));
ChEx.storage.saveLocalDirectly(STORAGE_KEY, data, () => location.reload());
} else {
let data = {};
$textarea.val().replace(/^([^:]+):\s*(.*?)\s*$/gm, (hit, key, val) => {
data[key] = val.replace(/\\n/g, "\n");
});
ChEx.storage.saveLocalDirectly(STORAGE_KEY, data, () => location.reload());
}
});
});
});
<!-- option.html -->
<meta charset="UTF-8">
<h1>Option</h1>
<form>
<div style="display: flex; height: 500px;">
<div class="ope-storage-array" data-col-rate="1" data-storage="example01.templates" data-title="example01.templates"></div>
<div class="ope-storage-object" data-col-rate="2" data-storage="example02.sums" data-title="example02.sums"></div>
</div>
<div style="display: flex; height: 300px;">
<div class="ope-storage-object" data-col-rate="1" data-storage="example07.comments" data-title="example07.comments"></div>
<div class="ope-storage-object" data-col-rate="1" data-storage="example08.notify" data-title="example08.notify"></div>
</div>
</form>
<script src="jquery-2.2.0.min.js"></script>
<script src="ChEx.js"></script>
<script src="ope-storage.js"></script>
10. gmail の宛名をドメインごとにまとめる
説明しやすいように抜粋してます
- ChEx.onChangeDom() … 下位のDOMが変更されたら
/** 下位のDOMが変更されたら */
ChEx.onChangeDom__timeoutId = 0;
ChEx.onChangeDom = function ($root, callback) {
$root.on("DOMNodeInserted DOMSubtreeModified", function () {
if (ChEx.onChangeDom__timeoutId) return true; //動いていたらやらない
ChEx.onChangeDom__timeoutId = setTimeout(function () {
try {
callback($root);
} finally {
ChEx.onChangeDom__timeoutId = 0;
}
return true;
}, 100);
});
};
- ChEx.h() … htmlエスケープ
ChEx.h = function (str) {
str = str.replace(/&/g, "&");
str = str.replace(/"/g, """);
str = str.replace(/'/g, "'");
str = str.replace(//g, ">");
return str;
};
- 下位のDOMの変更検出
- ドメインごとにまとめる
- gmailからアドレス取る
// content_script.js
$(function () {
ChEx.onChangeDom($('body'), function () {
//既存のボタン検索
$(`div[id][role='button']:contains('送信')`).each(function () {
let $sendButton = $(this);
let $parent = $sendButton.parent();
if ($parent.children('.chex-gmail-btn-confirm').length) return; //確認ボタンがまだ無い場合のみ
//送信ボタン非表示
$sendButton.hide();
//確認ボタン作成
let $confirmButton = $(`<div class="T-I J-J5-Ji aoO T-I-KE L3 chex-gmail-btn-confirm" role="button" tabindex="1" style="-webkit-user-select: none;">確認</div>`);
$confirmButton.on("click", function () {
//メール書き込みエリアを探す
let $dialogMail = $confirmButton.closest(`div[role='dialog'][aria-labelledby]`); //とりあえずダイアログ形式のみ
//確認ダイアログ表示
let dialog = ChEx.dialog({
title: '宛先を確認してください。',
css: { width: '600px' },
makeBody: function() {
let sender_map = {};
for (let key of ['to', 'cc', 'bcc']) {
//情報収集
let addrAry = $dialogMail.find(`input[name="${key}"]`).get().map(input => $(input).val());
//アドレスをドメインごとに振り分ける
for (let addr of addrAry) {
let domain = addr;
domain = domain.replace( /\s/g , "" ); //空欄は撤去
domain = domain.replace( /("|").*?("|")/g , "" ); //"~" は撤去
domain = domain.replace( /^.*(?:<|<)(.*?)(?:>|>).*$/ , '$1' ); //<~> ならその中身がメアド
if (!domain) return false;
domain = domain.replace( /^.*?@/ , "@" );
domain = domain.toLowerCase();
//マップに登録
if ( ! sender_map[domain] ) {
sender_map[domain] = [];
}
sender_map[domain].push( ChEx.h(addr) );
}
}
return $(`
<div>
${Object.keys(sender_map).sort().map(domain => `
<div class="chex-gmail-checkable" style="padding: 2px; margin: 20px 0; background-color: #DDDDDD; font-family: monospace;">
<div style="margin: 0; padding: 2px; font-weight: bold; font-size: large;">${domain}</div>
<div style="margin: 0; padding: 2px; background-color: white; line-height: 1.2;">${sender_map[domain].sort().join("<br/>\n")}</div>
</div>
`).join('')}
</div>
`);
},
});
dialog.open();
dialog.addButton('送信', () => {
dialog.close();
$sendButton.click();
});
});
$sendButton.before($confirmButton);
});
});
});
ちなみに
「Gmail送信前チェッカー」完全版はこちらで公開しています。
11. 単体テスト
ChEx.padding()
- ChEx.padding() … 数値や文字列のパディングや桁区切りなどのフォーマット。
/**
* 3桁区切り&パディング&左右寄せ
* @param num - 数値なら3桁区切り。文字列でもいい
* @param length - (省略可)この桁数までパディングする。マイナスなら左寄せ
* @param char - (省略可)パディングで埋める文字。デフォルトは空白
* @param decimal - (省略可)表示させたい小数桁
*/
ChEx.padding = function (num, length, char, decimal) {
if (!num.replace) { //文字列か
if (decimal) {
num = num.toLocaleString('ja-JP', { minimumFractionDigits: decimal, maximumFractionDigits: decimal });
} else {
num = num.toLocaleString();
}
}
if (length) {
char = char || ' ';
if (length > 0) {
num = char.repeat(length) + num;
num = num.slice(-length);
} else {
num = num + char.repeat(-length);
num = num.slice(0, -length);
}
}
return num;
};
- unitTest() … 簡易的な単体テスト
function unitTest($appendTo, callback) {
let $parent = $('<ul></ul>');
$appendTo.append(
$('<pre></pre>').append(
$parent
)
);
const test = (title, callback) => {
let $result;
let $case = $(`<li style="color: red;"></li>`).append(
ChEx.h(title),
' ... ',
$result = $('<span></span>')
);
$parent.append($case);
let $sandbox = $('<div></div>').appendTo($case).hide();
const assert = (actual, expected) => {
if (actual === expected) {
$case.css('color', 'green');
$result.text('OK');
} else {
$result.text(`Error! Expected '${expected}', but '${actual}'`);
}
$sandbox.remove();
};
try {
callback(assert, $sandbox);
} catch (e) {
$result.text(e.message);
$sandbox.remove();
}
};
callback(test);
}
// test.html
<script src="../src/jquery-2.2.0.min.js"></script>
<script src="../src/ChEx.js"></script>
<script src="test-helper.js"></script>
<script>
$(function () {
unitTest($('#test-result'), test => {
test('ChEx.padding(1234567) ', assert => assert(ChEx.padding(1234567) , '1,234,567'));
test('ChEx.padding(1, 3) ', assert => assert(ChEx.padding(1, 3) , ' 1'));
test('ChEx.padding(1, -3) ', assert => assert(ChEx.padding(1, -3) , '1 '));
test('ChEx.padding("a", 3) ', assert => assert(ChEx.padding("a", 3) , ' a'));
test('ChEx.padding("a", -3) ', assert => assert(ChEx.padding("a", -3) , 'a '));
test('ChEx.padding(1, 3, "0") ', assert => assert(ChEx.padding(1, 3, "0") , '001'));
test('ChEx.padding(0.12345, 6, "0", 2)', assert => assert(ChEx.padding(0.12345, 6, '0', 2), '000.12'));
});
});
</script>
その他のテスト
12. 結合テスト
- example() … 結合テスト(このファイル)
function example(testId, callback) {
$(function () {
let $test = $(`#${testId}`);
let params = ChEx.urlParams();
let on = (params.on === '1' && params['#'] === testId);
params.on = (on ? '0' : '1');
params.test = testId;
params['#'] = testId;
$test.find('h2').append(
$(`<a href="${ChEx.makeUrl(params)}">拡張を${on ? '無' : '有'}効化</a>`).css({
marginLeft: '20px',
fontSize: 'smaller',
fontWeight: 'normal'
})
);
if (on) callback($test);
});
}
<script src="../src/jquery-2.2.0.min.js"></script>
<script src="../src/ChEx.js"></script>
<script src="test-helper.js"></script>
<div id="example01">
<h2>1. 入力内容をテンプレとして保存する</h2>
<form>
<div><span>タイトル: </span><input type="text" name="a"></div>
<div><span>種別: </span><input type="radio" name="b" value="1">出退勤 <input type="radio" name="b" value="2">非出退勤</div>
<div><span>乗り物: </span><input type="checkbox" name="c" value="1">JR <input type="checkbox" name="c" value="2">地下鉄</div>
<div><span>プロジェクト: </span><select name="d"><option><option value="1">○○PROJ<option value="2">△△PROJ</select></div>
<div><span>メンバー: </span><select name="e" multiple size="4"><option value="100001">Aさん<option value="100002">Bさん<option value="100003">Cさん<option value="100004">Dさん</select></div>
<div><span>備考: </span><textarea name="f" rows="3"></textarea></div>
</form>
<script>
example('example01', function () {
//あらかじめ保存されているのを再現
ChEx.storage.saveLocal('example01.templates', [], templates => {
if (templates.length) return;
templates.push("家→会社1\t交通費(家→会社1)\t1\t2\t1\t100001,100003\t改行→\n←改行");
templates.push("会社1→会社2\t交通費(会社1→会社2)\t2\t1,2\t2\t100002,100004\t改行を\n含むメモ");
});
//テンプレート保存機能
ChEx.templateStorage({
storageKey: 'example01.templates',
init: $opener => $opener.appendTo('#example01 form'),
inputs: [
'#example01 [name="a"]',
'#example01 [name="b"]',
'#example01 [name="c"]',
'#example01 [name="d"]',
'#example01 [name="e"]',
'#example01 [name="f"]',
],
title: 'テンプレートから選択',
});
});
</script>
</div>