// ==UserScript==
// @name ニコ生アラート(簡)
// @name:ja ニコ生アラート (簡)
// @name:en Nico Live Alert (Kan)
// @namespace http://userscripts.org/users/347021
// @id niconico-alert-keyword-347021
// @version 5.0.0
// @description Alerts you to live streams that match your search. Supports these sites: FC2 Live, CaveTube, koebu LIVE!, SHOWROOM, Stickam JAPAN!, TwitCasting, Twitch, Niconico Live, Himawari Stream, Livetube
// @description:ja キーワードにヒットしたライブ配信を通知します。次のサイトに対応: FC2ライブ、CaveTube、 こえ部LIVE!、SHOWROOM、Stickam JAPAN!、ツイキャス、Twitch、ニコニコ生放送、ひまわりストリーム、Livetube
// @match http://*.nicovideo.jp/*
// @match *://live.fc2.com/*
// @match http://gae.cavelis.net/*
// @match http://koebu.com/*
// @match https://www.showroom-live.com/*
// @match http://www.stickam.jp/*
// @match http://twitcasting.tv/*
// @match http://www.twitch.tv/*
// @match http://himast.in/*
// @match *://www.ustream.tv/*
// @match *://www.youtube.com/*
// @match https://www.younow.com/*
// @match *://livestream.com/*
// @match *://livetube.cc/*
// @run-at document-start
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_openInTab
// @grant GM_info
// @icon data:image/vnd.microsoft.icon;base64,AAABAAEAMDAAAAEAIAAuDQAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAwAAAAMAgGAAAAVwL5hwAADPVJREFUaIHNWXl4lNW5/52Zb/aZZCaZLaskk4RAWAIKCAoahKBUYkGsqLXQ9kLpo9el7a1irxYLaa1elVpUtNYqYkvdA2KLgoqsBsEsJCwhC8Ssk22yzExmvu+8/SMzw2QyoSHRen/P8+X55nxn+b3bed9zAnzDcNgtAIB0W7yQYNBunj97ljgpLfWUUWCzACDNGs++aQ5fC7TAQ7+857+pr7eP9n28lyZellwHQP1t8xox5MD2I4cOEhFJREQ3XDvPDWDyt81rxNABK1fd+j2qKC+nl7Y8R+k2czFwwcX+XyEaKSKCgWkOpVuTKcGg26EGrIG+Y4oB2VgGDwdXFxtCKkZmX7NqtTD7R2ubQaRo9AKtAFDd7KRvgsMlIZrGbTrrHXrYiuwGy3cGWuxbjn6uoMoTAiXEWN8eOod5VGuP2QIOuwUXVJitBnJ1QPKE7Ini86+/01WQPVF6A5ilzEwUM5OSJXx5TEBTt/zoQP+UiUCKGQCG2uw/jBiZNefqy+OcNy820oRUc8PGh3VEBL5onomA5JUF+San2w16+AE9AcnTMxLMj9yyxEjzrojrU8GWBQwo41sMavuan91lIF8/qK5GTh3tjEQR/NWX1FLBQpP30V/piQhUsNAkAYkbctLMnUSgRx7UE5CwKjiLw3ZpAsi/LvopcZrm6ir5xA92qMytrTLFdfk+mcSBuHiS3b7KIyxa7AMAysySkDlOvGZCjqSel+fD+++pcOyooJEQI0uM0Zw93+H0O+wWdPa6vy5qF0e6zRyxFSbMuHxCfKskgrb+WUMWrfX88huNnWUlArm6GN/xjorqamVEBE4E3tHOaO+HSnrwfgONs1jeBACH3TxixY45iGta2lDd7CSH3RwQgk6rVOTZ/YEKH/1TCadb8f2yL4UTgkD47BMlW3lrbFnerPi6IwcVrL2N4Y9P6dDulGFcmgS5nBIAgPANR7TDbhnkq5FbYGKsZaZJYd0ep7TeCQDzrzR1EYHWP6QnIDFPCdsrJccF2lWkIi1sexnsvzSrrS847OZQMI8Ulyyqw24BCKhucUZ+sgCYpgAmKAGtUgEDwFT9fiEpJVl+y73/45W98qIcxRXSIiUQvyAv9q/9XrnnwGHFnH60lAQnSYmzsPqOkSe3SxIg3WZGt8sla/P6OQDIgSsJWJqRZC9ITErKjjdbkDpuHKw2G4wmE5RKJQiAKHKcq22jlqazrKG+Bm1OZ+uxU9UnAb7twfvytz226UMvAKRZ42W1re3cYbNEU9Do4LBbkB5hUi2wVAt8mD93Dj326Hr6ZM9H1NXZSf8GnIg4cU4nysro5Re20J233EzJJkOtAlgHQB+uqNFm5qgChMGmAt65dtYMemv738jn84lE5A+QI7/Px/0+Hxf9fhJFkYc/fr+ffD4flyQpKIxERL7y0hJas/JOSjbFNABYMViIMSY1h92CzEQ7AEAH3JRmje95bMOjgzVKxAO4oOoo75zzQe/h44mIjhw6SIvmXUVa4I2ZOeM1QSFGQTqq5Gumj8+kzz75OHQgCTAOFyLUFvE+qC0obPj3oFVEURTv++lailPKjwLQRFPov3Utu1Efyg2zp0/Rxcjw89lTJ1NdbU1QayRJEnEucUmSwkkOESD4O9Av0lRDBAziicINlGyK2acEMgBkTcvOiAtySjHFDKd5C7IC7mLSKbNNWsX2BIP2fG6Wg6qrzoQvODgywxou4i7k8/mIE4/sx8P7BS2xf9+nZNEoacbE8bRkQR5dFm88owN+FuIarV4Kuo3AkHWZxeR6+qf30JyMDHp96ytSGE9ORHznu29TbpaDv1/0Lg+YPlKrg3zc6Wzl+XPn0IqlBSGfDzPIoLZ/frCLMsxxdOsNi3hTY4Pk9XqkkuPH6MYFeaQCNkbyHYJ4vWr/qw88Qvuf2sLz584JELmwkCiKNP/KmSQHKH/uVYMCMdxFwsm99PxzXAmQRavi/3h/ZzQBiIio2+WiudNz+W/uWMWLDx4cJFhnRwfNyMkmo8DmhAswqBYyqOVXjE8ZN+0Hi27CG3v/gbwbFhMAEAAWOHF4PB40NTbAHqtHZ2cHXvrTC3hu8zMoLy8DYwyMMRBRqD8ANNSfh0EtQBAENNTXA0CoD2MsdCA6XnIccUyBxIQkZE8OXVgQAGY0mahg2c3oEWk+AFQ3O4cK0O+TLOPsybpeTx9On69l1y1cCABgYQlbq9VSmiMDflGk9pZWfPTyayjduRsrb7oJG3798ED/iONVzpSp6PGK4Jxj0pSpQ6zOBkiio72DjHoDDFod3O6h5bTNZgcHjEM+BBGrFqbPnZTTW/f6DlqQm8vr6s8R9XrJfbiMPF+e4tzjJSKiXUXvkU2vpteeeIqo083pcCWd21ZE01NTadeunUREJIpiKFg9bjfd85PV9Ot1D4QChYjI39LO3cUnyHuyhoiIKioraE5mJh16+k/83Tf+PmivICL6yco7SQWsDec8qO7uF3kTuH8BGEtr73Uhf0kBizndQF5nO6S2LoA4BLuZOdvbUfPFcSpcXwiZ3QTXybOwGUxMo1JR4bNPY/bVV7OEhEQKupNCocANNy5h1163gAIaZ9ztJc+BLyF19UBqbodCo2bWLAc+ObCP6iorYdUYEJuWgthYIwOAD3YUsU2P/75RRvx+t8j70gOHnpALBYPC3d//w217djqPV5Qzd2+PJBPkAA94qWxAXperC3K5AF5VD/feYsi8foABxphYiN29eGzdQ2hrawsphoiiv3MecDeC3+cDAGza/DzOSn3Y+tetuPu22/HaKy9j48P/y+5e/WNXY4fre21ef4vDbmE1zVGKvWDqnpyZnmBUsP1P/v53RETUd7SCvBXVnPf7OBFRc1MTzZw8kfYUbiL6tITTx8e49FEx3Tb3Gro6M5NWXH89OZ3OQTkhMn8QEfc3Obn7cCl5T5wl7vcPpHgi7na7+YtbnqM7li+jWDk4gN8mxeodAY7DV9DptkG3ApOmZqaLrp5uIqLQ5MFt7603/84npSbzR2/7Af/Lfet4wYyZfMG8Ofzu//ox/6K4ODwnDMkNkWVIKCwGyoqgpOIz//c4xQoI3SE5RlLgOewWpMYbZQCgAZ5c8d2CIcVXkMChgwf4PXet5au+fxt/9plNwYQWXuCFCzDkW5Q5Q+vsKnqPslOSvABSg7xGVZ2a1YpXF141mw7s+zR8R4iqQU6c+3y+qNoO1kdhm1BUS7j7+uiJwg2UbjN364G8S2cctMTgemOF3aA5ufT6fPrzluepvLSUul0uKeBZ0hBXCNv6hnkPf6R+r1cqLy2hJwo30sycCaQH3gaQEO4Vw2HYgAgOMlus+Ly8AslGg7qtq2dxP3B9dmrSonHp6alJSSmw2m1ITE6GzWZHTKwROr2ONBoNFEolEwQBcrkcjMkABpJEEV6PBy5XN2tpakRzUyPO1dWiuqoKpyoqG043NP0tRoa3ujk+jyReHW3XuZgAkYJETKDAwCF+OoDUGBmuADBNqVQ4NGq1AYyBE5FsoFYAAuUAEYFzDoVCUevz+Q67uvucbuAEgP0AqgGIg9YlQnVLGy6GER/q0+1m1DQPnUwLxNqs8e9OnpqbJwjyYOFEIGDgT2jfZ2AMnHM6WXHi9JmG5uUAKiLn+1oP9BdDMEb0wPp7164Zia+Hnu2vbaVko6ESABx2i+JS70PDIYx2YFBLjKG24auvUFZSgl53H2OMEaJblgEgg15P5+rqGBhrCbTzsWh8VHd4kZdbMQr2YmK8ZfW0rInkF0VQgGzEQkwhCHSupZGVVp05kWC1LDrb7Gx02C3DBuhIMCoLRC6o1Gp8P1qyHL+49Ydw9fUOO86g1WFf6VHc9YdCnKpvbAzONRa/H7UFqpudSDBo17h63Mu0asVVOWkZ+vgYIyQuDTtOLpPB6/NReU0Va3P1fKZVCW+3ef3PMMYwWkuM5Rp48tzLc8s2Pv4kTHFx6Bf9JEkSu+iMNHDY0Wt11NPtYr/7zXrs3r1nuRcY8j+zkWLUQQwgw2qzYd78+cHf4X4fGQNBscK/92dkZql27N6TNQYOY7KAanxywpGcKVNyBZVK6vf1y4PqZ4yRTq1mgZyAPq+HiCiQ0QgalZrEfh8rLy2pdbvd19Z3uM7/RwVIt1tQ0+xEkk5jdbk9hzffuy49IzmVunp7mCCXw6iPobv/UMi/qKs7lJNgn7H53l+pJc7h9fXDqDfg2JlKPPLyH2tdItKBgTKeseHLhYthVC7EAtm1oc/TGqsRioqrK++/Y/F3mRBvIfT10LNvbpV1+zynAFzT4ek9UtXSMHP10tsJOh3Edie9uneXTJLLtkHkcNjMMoB4dZQsPyIuoxqFCzuRw2ZR93j6Xp/iyFqWYk2As6sD5TVnKly9vcu7PP5Ttlhdtlqp2jE5PTPTpI9BvbMZVV+dK2ro6F4GgAetOVr8C9DD3qnMZcwMAAAAAElFTkSuQmCC
// @compatible Firefox
// @compatible Opera
// @compatible Chrome
// @author 100の人
// @homepage https://greasyfork.org/scripts/272
// @contributor HADAA
// @license Mozilla Public License Version 2.0 (MPL 2.0); https://www.mozilla.org/MPL/2.0/
// ==/UserScript==
// For UserScriptLoader.uc.js
if (typeof GM_info === 'undefined') {
window.GM_info = {
script: {
version: '5.0.0',
},
};
}
(function () {
'use strict';
defineGettext();
// L10N
Gettext.setLocalizedTexts({
'en': {
//'(取得不可)': '(No data)',
'検索ワードにヒットしたライブ配信番組': 'Live streams that match your search words',
'どのライブ配信サービスか': 'Service from',
'アイコン': 'Icon',
'プライベート配信か否か': 'Private program or not',
'限定公開': 'Limited',
'経過': 'Elapsed',
'%d 分': '%dm',
'%d 時間 %u 分': '%dh%um',
'配信開始からの経過時間': 'Time elapsed since start of live stream',
'タイトル': 'Title',
'番組のタイトル': 'Program title',
'タグ': 'Tags',
'カテゴリ・タグ': 'Category and tags',
'配信者': 'Broadcaster',
'配信者の名前': 'Broadcaster name',
'説明文': 'Description',
'来場': 'Visitors',
'来場者数': 'Number of visitors',
'%d 人': '%d',
'コメ数': 'Comments',
'%d コメ': '%d',
'総コメント数': 'Total number of comments',
'コミュニティ': 'Community',
'コミュニティ・チャンネル': 'Community or channel',
'%s 更新': 'Last updated %s', // %sは年月日
//'メンテナンス中': 'Under maintenance',
//'サーバーダウン': 'Server is down',
//'オフライン': 'Offline',
'検索語句': 'Search words',
'除外するコミュニティ・チャンネルなどの URL': 'Community or channel URLs to be excluded',
'保存': 'Save',
'除外 URL リストの取得先を設定': 'Sets the location of the URL exclusion list',
'特定の URL から、除外 URL のリストを読み込み、検索時に付加します。': 'Loads exclusion list from designated URL and adds to search.',
'JSON 形式の URL 文字列の配列のみ有効です。': 'Array of URLs needs to be in JSON format.',
'また、除外 URL リストの読み込みは、アラートページ読み込み時に1回だけ行われます。': 'Also, this script loads exclusion list only once when the alert page is opened.',
//'GM_xmlhttpRequest エラー': 'GM_xmlhttpRequest error',
'指定された URL から、除外 URL リストを読み込めませんでした。\n取得せずに続行します。\n\nエラーメッセージ:\n%s': 'Failure to fetch URL exclusion list from designated URL. Continue without fetching.\n\nError message:\n%s',
'追加設定ボックスの開閉': 'Toggle extra settings',
'検索対象のライブ配信サービス': 'Live streaming services for search',
'サービス名': 'Service name',
'最後に検索結果の取得に成功にした日時': 'Last successful search result timestamp',
'直近のエラー': 'Last error',
'FC2ライブ': 'FC2 Live',
'CaveTube': 'CaveTube',
'こえ部LIVE!': 'koebu LIVE!',
'SHOWROOM': 'SHOWROOM',
'Stickam JAPAN!': 'Stickam JAPAN!',
'ツイキャス': 'TwitCasting',
'Twitch': 'Twitch',
'ニコニコ生放送': 'Niconico Live',
'ひまわりストリーム': 'Himawari Stream',
'Ustream': 'Ustream',
'Youtube ライブ': 'Youtube Live',
'YouNow': 'YouNow',
'Livestream': 'Livestream',
'Livetube': 'Livetube',
'表示する項目の設定': 'Set which items to display',
'その他の設定': 'Other Settings',
'プライベート配信を通知しない': 'Do not notify about private programs',
'タイトル・キャプション・コミュニティ名が %d 文字を超えたら省略する': 'Truncate to %d characters if title description or community name is longer',
'言語で絞り込む (言語が取得可能なサービスのみ)': 'Filter by language (only for services that have this function)',
'アラート音': 'Alert sound',
'ファイルサイズが大きいため、設定に失敗しました。\n\nエラーメッセージ:\n%s': 'Failure to set because file is too large.\n\nError message:\n%s',
'使用中のブラウザが対応していないファイル形式です。': 'Your browser cannot play this type of file.',
'項目名クリックで番組を昇順・降順に並べ替えることができます。': 'If you click on item name, you can sort programs.',
'項目名をドラッグ&ドロップで列の位置を変更できます。': 'Drag and drop item name to change column position.',
'ユーザー名やコミュニティ名をテキストエリアにドラッグ&ドロップで除外 URL に指定できます。': 'Drag and drop user or community name to textarea to set URL exclusion filter.',
'RSSの取得に失敗しました。ページを更新してみてください。\n\nエラーメッセージ:\n%s\n%d 行目': 'Failure to read RSS file. Please refresh this page.\n\nError message:\n%s\non line %d',
'更新しますか?': 'Do you want to refresh?',
'指定された URL から NG リストを読み込めませんでした。\n取得せずに続行します。\n\nエラーメッセージ:\n%s': 'Failure to get communities and channels from the specified URL.\nScript will continue without getting them.\n\nError message:\n%s',
'設定のインポートとエクスポート': 'Import and export settings',
'JSONファイルからインポートする': 'Import from JSON file',
'JSON形式でファイルにエクスポート': 'Export to file in JSON format',
'インポートに失敗しました。\n\nエラーメッセージ:\n%s': 'Import failed.\n\nError message:\n%s',
'インポートが完了しました。ページを再読み込みします。': 'Import completed. Refreshing this page.',
'ローカルストレージの容量制限を超えたので、プロパティ %p を無視しました。': 'Because the capacity of local storage was exceeded, %p property is ignored.', // %pはカンマ区切りのプロパティ名
'値が壊れていたので、プロパティ %p を無視しました。': 'Because the value corrupted, %p property is ignored.', // %pはプロパティ名
'使用中のブラウザが対応していないファイル形式のため、プロパティ %p を無視しました。': 'Because your browser doesn\'t support this type of file, %p property is ignored.', // %pはプロパティ名
'アラート音を選択': 'Sets alert sound',
'設定済みのアラート音を削除': 'Deletes alert sound set in advance',
'設定済みのアラート音を削除': 'Deletes alert sound set in advance',
' ❰❰%s❱❱ ': ' <<%s>> ', // ツールチップ内における一致箇所のマーク
'ニコ生アラート (簡)': 'Nico Live alert (Kan)',
},
});
// クライアントの言語を設定する
Gettext.setLocale(window.navigator.language);
/**
* メインの処理を行うユーティリティークラス。
*/
var Alert = {
/**
* ページタイトル、ブラウジングコンテキスト名、ユーザースクリプトのコマンド名に用いる文字列。
* @constant {string}
*/
NAME: _('ニコ生アラート (簡)'),
/**
* {@link GM_setLargeString} や URL に用いる文字列。
* @constant {string}
*/
ID: 'alert-keyword-347021',
}
/**
* アラートページのURL。
* @type {string}
*/
var alertPageURL = 'http://live.nicovideo.jp/alert/?' + Alert.ID;
if (window.location.href !== alertPageURL) {
GM_registerMenuCommand(Alert.NAME, function () {
GM_openInTab(alertPageURL);
});
} else {
polyfill();
startScript(
main,
parent => parent.localName === 'body',
target => target.id === 'utility_link',
() => document.getElementById('utility_link')
);
}
function main () {
defineClasses();
// ページタイトル
document.title = Alert.NAME;
// Favicon
var icon = document.querySelector('[rel="icon"]');
icon.href = Alert.ICON;
document.head.appendChild(icon);
// 元のページ内容を削除
document.getElementById('all_cover').remove();
// ヘッダを修正
for (var select of document.querySelectorAll('[href^="javascript:"]')) {
select.target = '_self';
}
// リンク先を新しいタブで開く、スタイルの設定
document.head.insertAdjacentHTML('beforeend', h`
<base target="_blank" />
<style>
[aria-hidden="true"] {
display: none;
}
main {
margin: 1em;
/* フッターとブラウザ表示領域下端の隙間埋め */
min-height: calc(100vh - (2em /* mainの上下マージン */ + ${document.body.clientHeight}px));
}
main a:link {
color: mediumblue;
}
main a:visited {
color: midnightblue;
}
/*====================================
表
*/
#programs {
width: 100%;
}
#programs caption {
display: none;
}
main tr {
background: silver;
border-width: 1px;
border-style: solid none;
}
main thead th {
white-space: nowrap;
}
#programs tbody {
text-align: left;
border-top: solid;
border-bottom: solid;
}
/*------------------------------------
ボタンの左右のマージン
*/
main button {
margin-left: 0.2em;
margin-right: 0.2em;
}
/*------------------------------------
セル内容の右寄せ・改行禁止
*/
#programs [role="timer"],
#programs [aria-live="off"] {
text-align: right;
white-space: nowrap;
}
/*------------------------------------
行の背景色
*/
#programs tbody tr:nth-of-type(2n-1),
#user-setting-options tbody tr:nth-of-type(2n-1),
#programs tbody:not(.odd) + tfoot tr {
background: white;
}
main tr td,
main tr th {
padding: 3px;
}
/*------------------------------------
リンクでない文字列
*/
a:not(:link) {
color: unset;
text-decoration: unset;
}
/*------------------------------------
番組のアイコン
*/
[itemtype="http://schema.org/VideoObject"] [itemprop="image"] {
width: 64px;
}
/*------------------------------------
取得失敗 / 空文字列 / 空白文字
*/
#programs .illegal {
font-style: oblique;
}
/*------------------------------------
項目の移動
*/
.inserting-before {
border-left: solid lightskyblue thick;
}
.inserting-after {
border-right: solid lightskyblue thick;
}
#main-settings {
-webkit-column-count: 2; /* Opera and Google Chrome */
-moz-column-count: 2; /* Firefox */
column-count: 2;
}
#main-settings textarea {
width: 100%;
height: 20em;
}
/*------------------------------------
検索語句の強調表示
*/
main mark {
color: inherit;
background: khaki;
}
/*------------------------------------
省略記号の表示
*/
.ellipsis-left::before,
.ellipsis-right::after {
content: "…";
color: dimgray;
}
.ellipsis-left::before {
margin-right: 0.2em;
}
.ellipsis-right::after {
margin-left: 0.2em;
}
.ellipsis-left::before {
margin-right: 0.2em;
}
.ellipsis-right::after {
margin-left: 0.2em;
}
/*------------------------------------
Windows 版の Opera、Google Chrome における全文の表示
*/
main td {
position: relative;
}
[data-title]:hover::after {
content: attr(data-title);
position: absolute;
top: calc(100% + 0.2em);
left: 1em;
border: 1px solid dimgray;
background: khaki;
padding: 0.5em;
opacity: 0.9;
border-radius: 0.7em;
z-index: 1;
}
/*------------------------------------
ステータス
*/
#programs tfoot tr:first-of-type {
border-bottom: none;
}
#programs tfoot tr:last-of-type {
border-top: none;
}
/*------------------------------------
ライブ配信サービス一覧
*/
#user-setting-options tr {
background: whitesmoke;
}
#user-setting-options thead th {
padding-right: 1em;
}
/*====================================
追加設定ボックス
*/
#user-setting-options {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
}
#user-setting-options dt {
margin-top: 2em;
font-weight: bold;
}
#user-setting-options dd > * {
text-align: left;
}
#user-setting-options > :last-of-type li {
list-style: disc;
}
/*------------------------------------
▲▼
*/
[aria-controls="user-setting-options"]::before {
content: "▼";
color: darkslategray;
margin-right: 0.5em;
}
[aria-controls="user-setting-options"][aria-expanded="true"]::before {
content: "▲";
}
#user-setting-options[aria-hidden="true"] {
display: none;
}
/*====================================
ライブ配信サービスのアイコン
*/
[itemtype="http://schema.org/Organization"] [itemprop="logo"],
[itemtype="http://schema.org/BroadcastService"] [itemprop="image"] {
width: 16px;
}
</style>
`);
// 挿入
document.getElementById('utility_link').insertAdjacentHTML('beforebegin', h`<main id="${Alert.ID}">
<table id="programs" sortable="">
<caption>${_('検索ワードにヒットしたライブ配信番組')}</caption>
<thead>
<tr dropzone="move">
<th id="service" title="${_('どのライブ配信サービスか')}" draggable="true">
</th>
<th id="thumbnail" title="${_('アイコン')}" draggable="true">
</th>
<th id="member_only" title="${_('プライベート配信か否か')}" draggable="true">
</th>
<th id="pubDate" title="${_('配信開始からの経過時間')}" sorted="" draggable="true">
${_('経過')}
</th>
<th id="title" title="${_('番組のタイトル')}" draggable="true">
${_('タイトル')}
</th>
<th id="category" title="${_('カテゴリ・タグ')}" draggable="true">
${_('タグ')}
</th>
<th id="owner_name" title="${_('配信者の名前')}" draggable="true">
${_('配信者')}
</th>
<th id="description" title="${_('説明文')}" draggable="true">
${_('説明文')}
</th>
<th id="view" title="${_('来場者数')}" draggable="true">
${_('来場')}
</th>
<th id="num_res" title="${_('総コメント数')}" draggable="true">
${_('コメ数')}
</th>
<th id="community_name" title="${_('コミュニティ・チャンネル')}" draggable="true">
${_('コミュニティ')}
</th>
</tr>
</thead>
<tbody aria-live="polite" aria-relevant="additions">
<template>
<tr itemscope="" itemtype="http://schema.org/VideoObject">
<td itemprop="provider" itemscope="" itemtype="http://schema.org/Organization">
<data itemprop="name" value="">
<a itemprop="url">
<img itemprop="logo" src="dummy" />
</a>
</data>
</td>
<td>
<a itemprop="url"><img itemprop="image" src="dummy" hidden="" /></a>
</td>
<td>
<data itemprop="requiresSubscription" value="false"></data>
</td>
<td role="timer">
<time itemprop="duration" datetime="P365D" hidden=""></time>
</td>
<td>
<data itemprop="name" value="">
<a itemprop="url"></a>
</data>
</td>
<td>
<data itemprop="keywords" value="">
</data>
</td>
<td itemprop="author" itemscope="" itemtype="http://schema.org/Person">
<data itemprop="name" value="">
<a itemprop="workLocation"></a>
</data>
</td>
<td>
<data itemprop="description" value="">
</data>
</td>
<td aria-live="off" itemprop="interactionStatistic" itemscope="" itemtype="http://schema.org/InteractionCounter">
<data itemprop="userInteractionCount" value="-1">
</data>
</td>
<td aria-live="off">
<data itemprop="commentCount" value="-1"></data>
</td>
<td itemprop="productionCompany" itemscope="" itemtype="http://schema.org/PerformingGroup">
<data itemprop="name" value="">
<a itemprop="url"></a>
</data>
</td>
</tr>
</template>
</tbody>
<tfoot>
<tr>
<td colspan="11" role="status"></td>
</tr>
<tr>
<td colspan="11" role="status"></td>
</tr>
</tfoot>
</table>
</main>`);
/**
* 番組を表示する表。
* @type {HTMLTableElement}
*/
var table = document.getElementById('programs');
if (!('sortable' in table)) {
document.head.insertAdjacentHTML('beforeend', `<style>
/*====================================
列ごとの並べ替え
*/
#programs thead th:hover,
#programs thead th:focus {
cursor: pointer;
background: gainsboro;
}
/*------------------------------------
▲▼
*/
[sorted]::before {
content: "▲";
color: darkslategray;
margin-right: 0.5em;
}
[sorted*="reversed"]::before {
content: "▼";
}
</style>`);
for (var th of Array.from(table.getElementsByTagName('th'))) {
th.setAttribute('role', 'button');
th.tabIndex = 0;
}
table.tHead.addEventListener('keydown', function (event) {
if (event.key === ' ') {
event.preventDefault();
}
});
table.tHead.addEventListener('keyup', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
TableProcessor.sort(event.target);
event.target.blur();
}
});
}
/**
* アプリケーション全体を内包する main 要素。
* @type {HTMLElement}
*/
var main = document.getElementById(Alert.ID);
main.insertAdjacentHTML('beforeend', h`
<dl id="main-settings">
<dt>
${_('検索語句')}
<button name="save-searching-words" aria-controls="searching-words">${_('保存')}</button>
</dt>
<dd>
<textarea name="searching-words" id="searching-words"></textarea>
</dd>
<dt>
${_('除外するコミュニティ・チャンネルなどの URL')}
<button name="save-ng-communities" aria-controls="ng-communities">${_('保存')}</button>
<button name="sets-blacklist-uri">${_('除外 URL リストの取得先を設定')}</button></dt>
<dd>
<textarea name="ng-communities" id="ng-communities"></textarea>
</dd>
</dl>
<audio id="alert-tone" controls="" preload="auto" hidden=""></audio>
<button type="button" aria-expanded="false" aria-controls="user-setting-options">
${_('追加設定ボックスの開閉')}
</button>
<dl id="user-setting-options" aria-hidden="true">
<dt>${_('検索対象のライブ配信サービス')}</dt>
<dd>
<table>
<thead>
<tr>
<th>${_('サービス名')}</th>
<th>${_('最後に検索結果の取得に成功にした日時')}</th>
<th>${_('直近のエラー')}</th>
</tr>
</thead>
<tbody>` + Alert.services.map(function (service) {
return h`<tr itemscope="" itemtype="http://schema.org/BroadcastService">
<td>
<label itemprop="name">
<input name="target-services" value="${service.id}" type="checkbox">
<img itemprop="image" src="${service.icon}" alt="" />
${service.name}
</label>
</td>
<td role="status">
</td>
<td role="status">
</td>
</tr>`;
}).join('') + h`</tbody>
</table>
</dd>
<dt>${_('表示する項目の設定')}</dt>
<dd>
<ul id="visible-columns">` + Array.from(main.getElementsByTagName('th')).map(function (th) {
return h`<li>
<label>
<input name="visible-columns" value="${th.id}" aria-controls="${th.id}" type="checkbox" checked="">
${_(th.title)}
</label>
</li>`;
}).join('') + h`</ul>
</dd>
<dt>${_('その他の設定')}</dt>
<dd>
<ul>
<li>
<label>
<input name="exclusionMemberOnly" type="checkbox">
${_('プライベート配信を通知しない')}
</label>
</li>
<li>
<label>
<input name="ellipsisTooLongRSSData" type="checkbox" checked="">
${_('タイトル・キャプション・コミュニティ名が %d 文字を超えたら省略する').replace('%d', UserSettings.MAX_VISIBLE_CHARACTERS)}
</label>
</li>
<li>
<label>
<input name="languageFilter" type="checkbox" checked="">
${_('言語で絞り込む (言語が取得可能なサービスのみ)')}
</label>
</li>
</ul>
</dd>
<dt>${_('アラート音')}</dt>
<dd>
<button name="select-sound" type="button" aria-controls="alert-tone">${_('アラート音を選択')}</button>
<button name="delete-sound" type="button" aria-controls="alert-tone" hidden="">${_('設定済みのアラート音を削除')}</button>
<input hidden="" type="file" accept="audio/*" />
</dd>
<dt>${_('設定のインポートとエクスポート')}</dt>
<dd>
<button name="import" type="button">${_('JSONファイルからインポートする')}</button>
<button name="export" type="button">${_('JSON形式でファイルにエクスポート')}</button>
</dd>
<dt></dt>
<dd>
<ul>
<li>${_('項目名クリックで番組を昇順・降順に並べ替えることができます。')}</li>
<li>${_('項目名をドラッグ&ドロップで列の位置を変更できます。')}</li>
<li>${_('ユーザー名やコミュニティ名をテキストエリアにドラッグ&ドロップで除外 URL に指定できます。')}</li>
</ul>
</dd>
</dl>
`);
/**
* 列IDのリスト。
* @type {string[]}
*/
var columnNames = Array.from(table.querySelectorAll('thead th')).map(th => th.id);
/**
* 配信サービスのIDのリスト。
* @type {string[]}
*/
var serviceIds = Alert.services.map(service => service.id);
/**
* ユーザー設定をインポートする際に利用するJSONスキーマ。
* @type {Object}
*/
UserSettings.schema = {
type: 'object',
properties: {
version: {
type: 'string',
},
words: {
type: 'array',
items: {
type: 'string',
},
},
NGs: {
type: 'array',
items: {
type: 'string',
},
},
NGsURI: {
type: 'string',
},
order: {
type: 'object',
required: ['name', 'order'],
properties: {
name: {
type: 'string',
enum: columnNames,
},
order: {
type: 'string',
enum: ['asc', 'desc'],
},
},
default: function () {
var sortedTH = document.querySelector('#programs [sorted]');
return {
name: sortedTH.id,
order: TableProcessor.getSorting(sortedTH),
};
}(),
},
'columns-position': {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
enum: columnNames,
},
default: columnNames,
},
'visible-columns': {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
enum: columnNames,
},
default: ['service', 'member_only', 'pubDate', 'title', 'category', 'owner_name', 'description', 'view', 'num_res', 'community_name'],
},
'target-services': {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
enum: serviceIds,
},
default: serviceIds.filter(serviceId => ['ustream', 'youtube-live', 'younow', 'livestream'].indexOf(serviceId) === -1),
},
exclusionMemberOnly: {
type: 'boolean',
default: document.getElementsByName('exclusionMemberOnly')[0].checked,
},
ellipsisTooLongRSSData: {
type: 'boolean',
default: document.getElementsByName('ellipsisTooLongRSSData')[0].checked,
},
languageFilter: {
type: 'boolean',
default: document.getElementsByName('languageFilter')[0].checked,
},
audioMuted: {
type: 'boolean',
default: false,
},
audioVolume: {
type: 'number',
minimum: 0,
maximum: 1,
default: 1.0,
},
audioData: {
type: 'string',
pattern: '^data:(audio/[-_.0-9A-Za-z]+|video/ogg);base64,',
},
},
};
/**
* 設定保存時のバージョン番号。
* @type {?string}
*/
var version = GM_getValue('version');
// 検索語句
var words = GM_getValue('words');
if (words) {
words = JSON.parse(words);
UserSettings.words = UserSettings.parseWords(words);
document.getElementById('searching-words').value = words.join('\n') + '\n';
}
// 除外リスト
var NGs = GM_getValue('NGs');
if (NGs) {
NGs = JSON.parse(NGs);
UserSettings.exclusions = UserSettings.parseExclusions(NGs);
if (!version) {
// 5.0.0 より前のバージョンの設定であれば
GM_setValue('NGs', JSON.stringify(UserSettings.exclusions));
}
document.getElementById('ng-communities').value = UserSettings.exclusions.join('\n') + '\n';
}
// 検索対象のサービス
var targetServicesJSON = GM_getValue('target-services');
if (targetServicesJSON) {
UserSettings.enableServices(version, JSON.parse(targetServicesJSON));
} else {
UserSettings.enableServices(GM_info.script.version, UserSettings.schema.properties['target-services'].default);
}
// プライベート配信・長い文字列の省略・言語
for (var key of ['exclusionMemberOnly', 'ellipsisTooLongRSSData', 'languageFilter']) {
var input = document.getElementsByName(key)[0];
var savedValue = GM_getValue(key);
if (savedValue !== undefined && savedValue !== null) {
input.checked = savedValue;
}
}
/**
* アラート音を鳴らすための要素。
* @type {HTMLAudioElement}
*/
var alertTone = document.getElementById('alert-tone');
// 音声ファイル
var audioData = UserSettings.getLargeValue('audioData');
if (audioData) {
alertTone.src = audioData;
alertTone.hidden = false;
document.getElementsByName('delete-sound')[0].hidden = false;
}
// ミュート
if (GM_getValue('audioMuted')) {
alertTone.muted = true;
}
// 音量
var audioVolume = GM_getValue('audioVolume');
if (audioVolume !== undefined && audioVolume !== null) {
alertTone.volume = audioVolume;
}
// volumechangeイベント
alertTone.addEventListener('volumechange', function (event) {
if (event.target.muted) {
GM_setValue('audioMuted', true);
} else {
GM_deleteValue('audioMuted');
}
if (event.target.volume === UserSettings.schema.properties.audioVolume.default) {
GM_deleteValue('audioVolume');
} else {
GM_setValue('audioVolume', event.target.volume.toString());
}
});
// clickイベント
main.addEventListener('click', function (event) {
var target = event.target;
var name = target.name;
if (target.localName === 'button') {
if (name === 'sets-blacklist-uri') {
// 特定の URL から NG リストを読み込んで、検索時に付加
var newURL = window.prompt(_('特定の URL から、除外 URL のリストを読み込み、検索時に付加します。') + '\n'
+ _('JSON 形式の URL 文字列の配列のみ有効です。') + '\n'
+ _('また、除外 URL リストの読み込みは、アラートページ読み込み時に1回だけ行われます。'), GM_getValue('NGsURI', ''));
if (newURL !== null) {
GM_setValue('NGsURI', newURL);
}
} else if (name === 'import') {
// インポート
UserSettings.import();
} else if (name === 'export') {
// エクスポート
UserSettings.export();
} else if (name === 'select-sound') {
// アラート音の選択
document.querySelector('[accept="audio/*"]').click();
} else if (name === 'delete-sound') {
// 設定済みのアラート音の削除
target.hidden = true;
document.getElementById('alert-tone').hidden = true;
document.querySelector('[accept="audio/*"]').src = '';
UserSettings.deleteLargeValue('audioData');
} else if (target.getAttribute('aria-controls') === 'user-setting-options') {
// 追加設定ボックスの開閉
var previousOpened = target.getAttribute('aria-expanded') === 'true';
target.setAttribute('aria-expanded', previousOpened ? 'false' : 'true');
document.getElementById('user-setting-options').setAttribute('aria-hidden', previousOpened ? 'true' : 'false');
target.scrollIntoView(!previousOpened);
if (!previousOpened && !document.body.classList.contains('nofix')) {
window.scrollBy(0, -document.getElementById('siteHeader').clientHeight);
}
} else if (name === 'save-searching-words' || name === 'save-ng-communities') {
// 検索語句、検索から除外するユーザー・コミュニティ・チャンネルの保存
// 二重クリックを防止
target.disabled = true;
/**
* 対応するテキストエリア。
* @type {string}
*/
var textarea = document.getElementById(target.getAttribute('aria-controls'));
/**
* 前後の空白を削除したテキストエリアの値。
* @type {string}
*/
var trimedValue = textarea.value.trim();
/**
* 正規化後、空行を含めずに改行で分割し、重複を削除した値。
* @type {string[]}
*/
var values = trimedValue === ''
? []
: Array.from(new Set(trimedValue.split(/\s*\n\s*/).map(StringProcessor.normalize)));
/**
* 検索語句の保存なら真。
* @type {boolean}
*/
var seavingWords = name === 'save-searching-words';
/**
* {@link GM_setValue} に保存する値。
* @type {string[]}
*/
var savedValues;
// 解析、キャッシュ、保存
if (values.length > 0) {
if (seavingWords) {
// 検索語句
savedValues = values;
UserSettings.words = UserSettings.parseWords(values);
} else {
// 検索から除外するユーザー・コミュニティ・チャンネル
savedValues = UserSettings.parseExclusions(values);
UserSettings.exclusions = savedValues.concat(UserSettings.exclusionsFromExternal);
// すでに表示している番組を非表示に
TableProcessor.removeExclusions();
}
} else {
savedValues = [];
}
/**
* テキストエリアに出力しなおす、正規化後の入力値。末尾に空行を含みます。
* @type {string}
*/
var outputValue;
// 保存
var gmValueName = seavingWords ? 'words' : 'NGs';
if (savedValues.length > 0) {
GM_setValue(gmValueName, JSON.stringify(savedValues));
outputValue = savedValues.join('\n') + '\n';
} else {
GM_deleteValue(gmValueName);
outputValue = '';
}
// テキストエリアに正規化後の文字列を出力
if (textarea.value !== outputValue) {
textarea.value = outputValue;
}
if (seavingWords) {
Alert.restart();
}
// クリック禁止を解除
target.disabled = false;
}
} else if (target.localName === 'th') {
// 列ごとの並び替え
if (!('sorted' in target)) {
TableProcessor.sort(target);
event.target.blur();
}
}
});
// changeイベント
document.getElementById('user-setting-options').addEventListener('change', function (event) {
var input = event.target;
switch (input.name) {
case 'visible-columns':
// 表示する項目の選択
var th = document.getElementById(input.value);
if (input.checked) {
TableProcessor.showColumn(th);
} else {
TableProcessor.hideColumn(th);
}
GM_setValue('visible-columns', JSON.stringify(UserSettings.getShownColumns()));
break;
case 'target-services':
// 検索対象のサービスの選択
if (input.checked) {
Alert.enableService(input.value);
} else {
Alert.disableService(input.value);
}
GM_setValue('target-services', JSON.stringify(UserSettings.getTargetServices()));
break;
case 'exclusionMemberOnly':
// プライベート配信の非表示
if (input.checked) {
TableProcessor.removePrivatePrograms();
GM_setValue('exclusionMemberOnly', true);
} else {
GM_deleteValue('exclusionMemberOnly');
}
break;
case 'ellipsisTooLongRSSData':
// 文字数制限を超えている場合に省略
if (input.checked) {
GM_deleteValue('ellipsisTooLongRSSData');
} else {
GM_setValue('ellipsisTooLongRSSData', false);
}
break;
case 'languageFilter':
// 言語で絞り込み
if (input.checked) {
GM_deleteValue('languageFilter');
} else {
GM_setValue('languageFilter', false);
}
break;
default:
if (input.accept === 'audio/*') {
// 音楽ファイルが選択された時
var file = input.files[0];
if (file) {
var alertTone = document.getElementById('alert-tone');
if (alertTone.canPlayType(file.type)) {
var alertReader = new FileReader();
alertReader.addEventListener('load', function (event) {
UserSettings.setAudioData(event.target.result);
});
alertReader.readAsDataURL(file);
} else {
window.alert(_('使用中のブラウザが対応していないファイル形式です。'));
}
}
}
}
});
// ダブルクリックした検索語句・除外URLを新しいタブで開く
document.getElementById('main-settings').addEventListener('dblclick', function (event) {
var textarea = event.target;
if (textarea.localName === 'textarea') {
/**
* ダブルクリックされた位置。
* @type {number}
*/
var clickedPosition = textarea.selectionStart;
// ダブルクリックされた行を取得
var words = textarea.value;
var beginSlice = words.lastIndexOf(
'\n',
words.slice(clickedPosition, clickedPosition + 1) === '\n' ? clickedPosition - 1 : clickedPosition
);
if (beginSlice === -1) {
beginSlice = 0;
}
var endSlice = words.indexOf('\n', clickedPosition);
var word = words.slice(beginSlice === -1 ? 0 : beginSlice, endSlice === -1 ? undefined : endSlice).trim();
if (word) {
var url = textarea.name === 'searching-words'
? 'http://live.nicovideo.jp/search/' + encodeURIComponent(word)
: UserSettings.parseExclusion(word);
if (url) {
window.open(url);
}
}
}
});
/**
* 現在のドラッグイベント。
* @type {DragEvent}
*/
var draggingEvent;
// 列の位置
table.addEventListener('dragstart', function (event) {
draggingEvent = event;
if (event.target.localName === 'th' && document.querySelector('#programs thead tr').contains(event.target)) {
event.dataTransfer.setData('Text', '');
// 他のスクリプトを抑制
event.stopPropagation();
}
});
var headRow = document.querySelector('#programs thead tr');
headRow.addEventListener('drag', function (event) {
if (event.target.localName === 'th') {
// 他のスクリプトを抑制
event.stopPropagation();
}
});
headRow.addEventListener('dragover', function (event) {
if (draggingEvent.target.localName === 'th' && event.currentTarget.contains(draggingEvent.target)) {
event.preventDefault();
// 項目の移動先
var className = event.pageX < event.target.offsetLeft + event.target.offsetWidth / 2 ? 'inserting-before' : 'inserting-after';
if (!event.target.classList.contains(className)) {
TableProcessor.removeOldClassName();
event.target.classList.add(className);
}
}
});
headRow.addEventListener('dragleave', function (event) {
if (draggingEvent.target.localName === 'th' && event.currentTarget.contains(draggingEvent.target)) {
TableProcessor.removeOldClassName();
}
});
main.addEventListener('drop', function (event) {
var row = event.currentTarget.querySelector('thead tr');
if (draggingEvent.target.localName === 'th' && row.contains(draggingEvent.target) && event.target.localName === 'th' && row.contains(event.target)) {
// 項目を移動
var refIndex = event.target.cellIndex + (event.target.classList.contains('inserting-before') ? 0 : 1);
if (draggingEvent.target.cellIndex !== refIndex) {
// 変更があれば
TableProcessor.moveColumn(draggingEvent.target, refIndex);
// 設定を保存
GM_setValue('columns-position', JSON.stringify(TableProcessor.getColumnPositions()));
}
event.target.classList.remove('inserting-before');
event.target.classList.remove('inserting-after');
} else if (event.target.name === 'ng-communities') {
// NG コミュニティを追加
event.preventDefault();
// 他のスクリプトを阻害しないよう dragend イベントを発生させておく
var init = {};
for (var key in draggingEvent) {
init[key] = draggingEvent[key];
}
draggingEvent.target.dispatchEvent(new DragEvent('dragend', init));
event.target.value += '\n' + event.dataTransfer.getData('Text');
document.getElementsByName('save-ng-communities')[0].click();
}
});
// 列の位置
var columnPositions = GM_getValue('columns-position');
if (columnPositions) {
TableProcessor.reflectColumnPositions(version, JSON.parse(columnPositions));
}
// 並び順
var order = GM_getValue('order');
if (order) {
order = JSON.parse(order);
if (order.name !== UserSettings.schema.properties.order.default.name
|| order.order !== UserSettings.schema.properties.order.default.order) {
TableProcessor.sort(document.getElementById(order.name));
} else {
GM_deleteValue('order')
}
}
// ソート時に並び順を保存
table.addEventListener('sort', function (event) {
var tHead = event.target.tHead;
new MutationObserver(function (mutations, observer) {
observer.disconnect();
var sortedTH = tHead.querySelector('[sorted]');
GM_setValue('order', JSON.stringify({
name: sortedTH.id,
order: TableProcessor.getSorting(sortedTH),
}));
}).observe(tHead, {
subtree: true,
attributeFilter: ['sorted'],
});
});
// 表示される列
var visibleColumnsJSON = GM_getValue('visible-columns');
UserSettings.showColumns(version, visibleColumnsJSON
? JSON.parse(visibleColumnsJSON)
: UserSettings.schema.properties['visible-columns'].default);
// 現在のバージョン番号を保存
GM_setValue('version', GM_info.script.version);
// 外部からの除外リスト取得
var NGsURI = GM_getValue('NGsURI');
var preprocessing;
if (NGsURI) {
preprocessing = new HTTPRequest({
method: 'GET',
url: NGsURI,
responseType: 'json',
timeout: 30 * DateUtils.SECONDS_TO_MILISECONDS,
mode: 'no-cors',
}).send().then(function (response) {
UserSettings.exclusionsFromExternal = UserSettings.parseExclusions(response);
UserSettings.exclusions = UserSettings.exclusions.concat(UserSettings.exclusionsFromExternal);
}).catch(function (error) {
window.alert(_('指定された URL から、除外 URL リストを読み込めませんでした。\n取得せずに続行します。\n\nエラーメッセージ:\n%s').replace('%s', error));
}).then();
} else {
preprocessing = Promise.resolve();
}
// 検索開始
preprocessing.then(function () {
Alert.initialize();
Alert.restart();
// 経過時間の定期的な更新
TableProcessor.startUpdatingDurations();
});
}
function defineClasses() {
/**
* ページのFaviconに用いるデータURL。
* @constant {string}
*/
Alert.ICON = 'data:image/vnd.microsoft.icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABvb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//AAAAAAAAAAAAAAAAAAAAAAAAAABvb2//2drc/9na3P///////////42Hsv+Nh7L////////////Z2tz/2drc/29vb/8AAAAAAAAAAAAAAABvb2//qqqq/////////////////42Hsv/EufX/xLn1/42Hsv///////////9na3P+qqqr/b29v/wAAAABvb2//jYey///////EufX///////////+Nh7L//////8S59f+Nh7L////////////EufX/2drc/42Hsv9vb2//b29v/8S59f///////////xUYI////////////42Hsv+Nh7L///////////8VGCP//////9na3P/EufX/b29v/29vb//EufX///////////8VGCP/////////////////////////////////FRgj///////Z2tz/xLn1/29vb/8AAAAAb29v/9na3P//////////////////////////////////////////////////////2drc/29vb/8AAAAAAAAAAAAAAABvb2//b29v/9na3P/////////////////////////////////Z2tz/b29v/29vb/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABvb2//b29v/9na3P/Z2tz/2drc/9na3P9vb2//b29v/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABvb2//APD//w1qdv9vb2//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG9vb/8Nanb/BMbU/w1qdv8Nanb/APD//w1qdv9vb2//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABvb2//BMbU/wDw//8ExtT/DWp2/wTG1P8A8P//b29v/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG9vb/8Nanb/b29v/29vb/8Nanb/b29v/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAb29v/9na3P+/wMP/b29v/wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG9vb///////2drc/29vb/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAb29v/29vb/8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AcAAMADAACAAQAAAAAAAAAAAAAAAAAAgAEAAMADAADwDwAA/D8AAPAPAADwDwAA+B8AAPw/AAD8PwAA/n8AAA==';
/**
* 初期化。
*/
Alert.initialize = function () {
// 各サービスのインスタンスにイベントリスナーを設定
for (var service of this.services) {
service.addEventListener('progress', this.onprogress);
service.addEventListener('load', this.onload.bind(this));
service.addEventListener('error', this.onerror);
}
// Faviconの設定
this.favico = new Favico({
animation: 'none',
position : 'up',
});
};
/**
* @param {Service#ProgramEvent}
* @access private
*/
Alert.onprogress = function (event) {
var program = event.detail;
if (!program.private || !document.getElementsByName('exclusionMemberOnly')[0].checked) {
// プライベート配信で無い、またはプライベート配信を拒否していなければ
var urls = [program.link];
if (program.author && program.author.url) {
urls.push(program.author.url);
}
if (program.community && program.community.url) {
urls.push(program.community.url);
}
if (urls.every(url => UserSettings.exclusions.indexOf(url) === -1)) {
// 除外リストに含まれていなければ
// 一覧に追加
TableProcessor.insertProgram(program, urls);
// アラート音を鳴らす
var alertTone = document.getElementById('alert-tone');
if (!alertTone.hidden && !alertTone.muted && alertTone.volume > 0) {
alertTone.play();
}
}
}
};
/**
* @param {Service#LoadedEvent}
* @access private
*/
Alert.onload = function (event) {
// 配信終了の番組を削除
TableProcessor.removeOldPrograms(event.detail.service, event.detail.programs, event.detail.searchCriteria);
// 最終更新日時を設定
UserSettings.showLatestUpdatedDate(event.target);
// ヒット数の更新
this.showHits();
};
/**
* ヒット数を更新します。
*/
Alert.showHits = function () {
var tBody = document.querySelector('#programs tbody');
/**
* ヒット数。
* @type {number}
*/
var hits = tBody.rows.length;
// ページタイトルの修正
document.title = (hits > 0 ? `(${hits})` : '') + Alert.NAME;
// Faviconの修正
this.favico.badge(hits);
// 行の色分けの調整
if (hits % 2 === 0) {
tBody.classList.remove('odd');
} else {
tBody.classList.add('odd');
}
};
/**
* スクリプトが動作している状態であれば真。
* @type {boolean}
* @access private
*/
Alert.run = false;
/**
* スクリプトが停止している状態であれば起動し、動作している状態であればOR検索ができないサービスを再読み込みします。
* 検索語句が空の場合は、スクリプトを停止します。
*/
Alert.restart = function () {
if (UserSettings.words.length === 0) {
// 検索語句が空であれば
this.stop();
} else if (this.run) {
// スクリプトが動作している状態であれば
for (var service of this.services) {
if (service.disabledOr) {
service.reset();
}
}
} else {
// スクリプトが停止している状態であれば
// 有効なサービスで検索を開始
this.run = true;
var enabledServices = UserSettings.getTargetServices();
for (var service of this.services) {
if (enabledServices.indexOf(service.id) !== -1) {
service.start();
}
}
}
};
/**
* スクリプトを停止します。
*/
Alert.stop = function () {
if (this.run) {
// スクリプトが動作している状態であれば、すべてのサービスで検索を停止
this.run = false;
for (var service of this.services) {
service.stop();
}
}
};
/**
* 指定されたサービスを有効化します。
* @param {string} id
*/
Alert.enableService = function (id) {
if (this.run) {
this.services.find(service => service.id === id).start();
}
},
/**
* 指定されたサービスを無効化します。
* @param {string} id
*/
Alert.disableService = function (id) {
if (this.run) {
var service = this.services.find(service => service.id === id);
service.stop();
TableProcessor.removeProgramsWithService(service);
}
},
/**
* ライブストリーミング配信サービスのリスト。
* @type {Service[]}
*/
Alert.services = [new Service({
id: 'fc2-live',
name: _('FC2ライブ'),
url: 'http://live.fc2.com/',
disabledSearch: true,
disabledLanguageFilter: true,
getDetails: function () {
return {
mode: 'no-cors',
method: 'GET',
url: 'https://live.fc2.com/contents/allchannellist.php',
responseType: 'json',
};
},
parseResponse: function (response) {
return { programs: response.channel.filter(function (program) {
return program.type !== 2 && program.login !== 2 && program.pay === 0;
}) };
},
convertIntoEntry: function (program) {
var url = 'http://live.fc2.com/' + program.id;
return new Program(url, program.title, {
icon: program.image,
published: new Date(program.start.replace(' ', 'T') + '+09:00'),
categories: program.category === 0 ? null : [['雑談', 'ゲーム', '作業', '動画', 'その他', , '公式'][program.category - 1]],
author: {
name: program.name,
url: url,
},
visitors: Number.parseInt(program.total),
language: program.lang,
});
},
}), new Service({
id: 'cavetube',
name: _('CaveTube'),
url: 'http://gae.cavelis.net/',
disabledSearch: true,
getDetails: function () {
return {
mode: 'no-cors',
method: 'GET',
url: 'http://rss.cavelis.net/index_live.xml',
responseType: 'document',
};
},
parseResponse: function (response) {
return { programs: response.getElementsByTagNameNS(DOMUtils.ATOM_NAMESPACE, 'entry') };
},
/**
* CaveTube名前空間。
* @constant {string}
*/
CT_NAMESPACE: 'http://gae.cavelis.net',
convertIntoEntry: function (program) {
var name = program.getElementsByTagNameNS(DOMUtils.ATOM_NAMESPACE, 'name')[0].textContent;
var parser = new DOMParser();
return new Program(
program.getElementsByTagNameNS(DOMUtils.ATOM_NAMESPACE, 'link')[0].getAttribute('href'),
parser.parseFromString(program.getElementsByTagNameNS(DOMUtils.ATOM_NAMESPACE, 'title')[0].textContent, 'text/html').body.textContent,
{
icon: new URL(program.getElementsByTagNameNS(this.CT_NAMESPACE, 'thumbnail_path')[0].textContent, 'http://gae.cavelis.net/').href,
published: new Date(program.getElementsByTagNameNS(DOMUtils.ATOM_NAMESPACE, 'published')[0].textContent),
author: {
name: name,
url: 'http://gae.cavelis.net/user/' + encodeURIComponent(name),
},
categories: program.getElementsByTagNameNS(this.CT_NAMESPACE, 'tag')[0].textContent.split(' '),
summary: parser.parseFromString(program.getElementsByTagNameNS(DOMUtils.ATOM_NAMESPACE, 'summary')[0].textContent, 'text/html').body.textContent,
visitors: Number.parseInt(program.getElementsByTagNameNS(this.CT_NAMESPACE, 'viewer')[0].textContent),
comments: Number.parseInt(program.getElementsByTagNameNS(this.CT_NAMESPACE, 'comment_num')[0].textContent),
}
);
},
}), new Service({
id: 'koebu-live',
name: _('こえ部LIVE!'),
url: 'http://koebu.com/live/',
icon: 'http://koebu.com/favicon.ico',
disabledSearch: true,
getDetails: function () {
return {
mode: 'no-cors',
method: 'GET',
url: 'http://koebu.com/live/list?page=1',
responseType: 'document',
};
},
parseResponse: function (response, details) {
var programs = { programs: response.getElementsByClassName('unitCh') };
var nextPage = response.querySelector('.pager .next a');
if (nextPage) {
details.url = nextPage.href;
programs.next = details;
}
return programs;
},
convertIntoEntry: function (program) {
var unitThumb = program.querySelector('.unitThumb img');
var masterUnitChThumb = program.querySelector('.masterUnitCh img');
var link = unitThumb.parentElement.href;
return new Program(link, unitThumb.alt, {
icon: unitThumb.src,
author: {
name: masterUnitChThumb.alt,
url: masterUnitChThumb.parentElement.href,
},
categories: Array.from(program.querySelectorAll('[rel="tag"]'), function (anchor) {
return anchor.text;
}),
summary: program.getElementsByClassName('description')[0].textContent,
visitors: Number.parseInt(program.querySelector('.ttlNumListen ~ dd').textContent),
community: {
name: unitThumb.alt,
url: link,
},
});
},
}), new Service({
id: 'showroom',
name: _('SHOWROOM'),
url: 'https://www.showroom-live.com/',
disabledSearch: true,
getDetails: function () {
return {
mode: 'no-cors',
method: 'GET',
url: 'https://www.showroom-live.com/onlive',
responseType: 'document',
};
},
parseResponse: function (response) {
return { programs: response.getElementsByClassName('onlive-list-li') };
},
convertIntoEntry: function (program) {
return new Program(program.getElementsByClassName('overview-link')[0].href, program.getElementsByClassName('tx-title')[0].textContent, {
icon: program.getElementsByClassName('img-main')[0].dataset.src,
published: DateUtils.parseJSTString(program.getElementsByClassName('time')[0].textContent),
visitors: Number.parseInt(program.getElementsByClassName('view')[0].textContent),
});
},
}), new Service({
id: 'stickam-japan',
name: _('Stickam JAPAN!'),
url: 'http://www.stickam.jp/',
disabledSearch: true,
getDetails: function () {
return {
mode: 'no-cors',
method: 'GET',
url: 'http://www.stickam.jp/explore/session?page=1',
responseType: 'document',
};
},
parseResponse: function (response, details) {
var programs = { programs: response.getElementsByClassName('col-md-3 col-sm-3') };
var nextPage = response.querySelector('.pagination > li:last-of-type:not(.disabled) a');
if (nextPage) {
details.url = nextPage.href;
programs.next = details;
}
return programs;
},
convertIntoEntry: function (program) {
var userNameLink = program.getElementsByTagName('a')[0];
return new Program(userNameLink.href, userNameLink.text, {
icon: program.getElementsByClassName('embed-responsive-item')[0].dataset.src,
private: Boolean(program.getElementsByClassName('status')[0]),
published: DateUtils.parseJSTString(program.getElementsByClassName('post-info')[0].textContent.replace('~', '')),
author: {
name: userNameLink.text,
url: userNameLink.href.replace('stickon#webcam', ''),
},
summary: program.getElementsByTagName('p')[0].textContent,
});
},
}), new Service({
id: 'twitcasting',
name: _('ツイキャス'),
url: 'http://twitcasting.tv/',
disabledOr: true,
disabledMinus: true,
disabledLanguageFilter: true,
getDetails: function (searchCriteria) {
return {
mode: 'no-cors',
method: 'GET',
url: 'http://twitcasting.tv/search/text/' + encodeURIComponent(searchCriteria.plus.join(' ')),
responseType: 'document',
};
},
parseResponse: function (response) {
return { programs: response.querySelectorAll('#content > div:first-of-type td') };
},
convertIntoEntry: function (program) {
var url = program.querySelector('.searcheduser a').href;
var titleAndComments = /(.*?)(?: \((0|[1-9][0-9]*)\))?$/.exec(program.querySelector('.title a').text);
var language;
switch (program.getElementsByClassName('countryflag')[0].src) {
case 'http://twitcasting.tv/img/c/us.gif':
language = 'en';
break;
case 'http://twitcasting.tv/img/c/br.gif':
language = 'pt';
break;
case 'http://twitcasting.tv/img/c/mx.gif':
language = 'es';
break;
case 'http://twitcasting.tv/img/c/jp.gif':
language = 'ja';
break;
}
return new Program(url, titleAndComments[1], {
icon: program.getElementsByClassName('icon32')[0].src,
author: {
name: program.getElementsByClassName('fullname')[0].textContent,
url: url,
},
categories: Array.from(program.getElementsByClassName('tag'), function (anchor) {
return anchor.text;
}),
summary: program.getElementsByClassName('userdesc')[0].textContent.trim(),
comments: titleAndComments[2] ? Number.parseInt(titleAndComments[2]) : null,
language: language,
});
},
}), new Service({
id: 'twitch',
name: _('Twitch'),
url: 'http://www.twitch.tv/',
/**
* 検索結果の最大件数。
* @constant {number}
*/
MAX_RESULT_LENGTH: 100,
disabledOr: true,
disabledMinus: true,
disabledLanguageFilter: true,
getDetails: function (searchCriteria) {
return {
mode: 'no-cors',
method: 'GET',
url: `https://api.twitch.tv/kraken/search/streams?limit=${this.MAX_RESULT_LENGTH}&q=${encodeURIComponent(searchCriteria.plus.join(' '))}`,
responseType: 'json',
};
},
parseResponse: function (response) {
return { programs: response.streams };
},
convertIntoEntry: function (program) {
return new Program(program.channel.url, program.channel.status || 'Untitled Broadcast', {
icon: program.channel.logo || program.channel.profile_banner,
published: new Date(program.created_at),
author: {
name: program.channel.display_name,
url: program.channel.url + '/profile',
},
categories: program.channel.game ? [program.channel.game] : null,
visitors: program.viewers,
language: program.channel.language,
});
},
}), new Service({
id: 'niconico-live',
name: _('ニコニコ生放送'),
url: 'http://live.nicovideo.jp/',
icon: 'http://nl.simg.jp/public/inc/assets/zero/img/base/favicon.ico',
/**
* 検索結果の最大件数。
* @constant {number}
*/
MAX_RESULT_LENGTH: 100,
getDetails: function (words) {
return {
mode: 'cors',
method: 'POST',
url: 'http://api.search.nicovideo.jp/api/',
responseType: 'text',
data: {
query: words.map(function (searchCriteria) {
var word = searchCriteria.plus.join(' ');
if (searchCriteria.minus.length > 0) {
word += ' -' + searchCriteria.minus.join(' -')
}
return word;
}).join(' OR '),
service: ['live'],
search: ['title', 'tags', 'description'],
join: [
'cmsid', // ID
'title', // タイトル
'member_only', // プライベート
'start_time', // 開始時刻
'community_icon', // コミュニティアイコン
'tags', // タグ
'description', // 詳細
'community_id', // コミュニティID
'channel_id',
'view_counter',
'comment_counter',
],
filters: [
{
type: 'equal',
field: 'live_status',
value: 'onair',
}
],
sort_by: 'start_time',
from: 0,
size: this.MAX_RESULT_LENGTH,
issuer: 'ニコ生アラート(簡)',
reason: 'ma10',
},
};
},
parseResponse: function (response, details) {
var programs = { programs: [] };
for (var jsonString of response.trim().split('\n')) {
var data = JSON.parse(jsonString);
if (data.type === 'hits') {
if (data.endofstream) {
break;
}
programs.programs = data.values;
if (programs.programs.length === details.data.size) {
details.data.from = programs.programs[details.data.size - 1]._rowid + 1;
programs.next = details;
}
break;
}
}
return programs;
},
convertIntoEntry: function (program) {
var otherDetails = {
icon: program.community_icon,
private: Boolean(program.member_only),
published: new Date(program.start_time.replace(' ', 'T') + '+09:00'),
// <br /> タグを半角スペースに置き換え、他のタグは取り除く
summary: program.description.replace(/<br( )\/>|<font[^>]+>|<\/?(?:font|b|i|s|u)>/g, '$1'),
categories: program.tags.split(' '),
visitors: program.view_counter,
comments: program.comment_counter,
};
if (p(otherDetails.summary).includes('&')) {
// 文字参照が含まれていれば
otherDetails.summary = new DOMParser().parseFromString(otherDetails.summary, 'text/html').body.textContent;
}
if (program.community_id || program.channel_id) {
otherDetails.community = {
name: program.community_id ? 'co' + program.community_id : 'ch' + program.channel_id,
};
otherDetails.community.url = 'http://com.nicovideo.jp/community/' + otherDetails.community.name;
}
return new Program('http://live.nicovideo.jp/watch/' + program.cmsid, program.title, otherDetails);
},
delay: 1 * DateUtils.MINUTES_TO_MILISECONDS,
}), new Service({
id: 'himawari-stream',
name: _('ひまわりストリーム'),
url: 'http://himast.in/',
disabledSearch: true,
getDetails: function () {
return {
method: 'GET',
url: 'http://himast.in/?mode=program&cat=search&sort=st_start_date&st_status=1&rss=1',
responseType: 'document',
};
},
parseResponse: function (response) {
return { programs: response.getElementsByTagName('item') };
},
convertIntoEntry: function (program) {
var details = [];
var description = new DOMParser().parseFromString(program.getElementsByTagName('description')[0].textContent, 'text/html');
var summary;
for (var node of description.getElementsByClassName('riRssContributor')[0].childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
details.push([node.data.replace(/^\s+|:|:/g, '')]);
} else if (node.localName === 'b') {
details[details.length - 1][1] = node.textContent;
} else {
summary = node.nextSibling.data;
break;
}
}
return new Program(
program.getElementsByTagName('link')[0].textContent,
program.getElementsByTagName('title')[0].textContent,
{
icon: description.getElementsByTagName('img')[0].src,
published: new Date(program.getElementsByTagName('pubDate')[0].textContent),
author: {
name: details.find(function (detail) {
return detail[0] === '配信者';
})[1],
},
summary: summary,
visitors: Number.parseInt(details.find(function (detail) {
return detail[0] === '延べ入場者数';
})[1]),
comments: Number.parseInt(details.find(function (detail) {
return detail[0] === 'コメント数';
})[1]),
}
);
},
}), new Service({
id: 'ustream',
name: _('Ustream'),
url: 'http://www.ustream.tv/',
icon: 'http://static-cdn1.ustream.tv/images/favicon-black:1.ico',
disabledOr: true,
disabledMinus: true,
getDetails: function (searchCriteria) {
return {
mode: 'no-cors',
method: 'GET',
url: 'https://www.ustream.tv/ajax/search.json?type=live&q=' + encodeURIComponent(searchCriteria.plus.join(' ')),
responseType: 'json',
};
},
parseResponse: function (response) {
var doc = new DOMParser().parseFromString(response.pageContent, 'text/html');
doc.head.insertAdjacentHTML('beforeend', h`<base href="${this.url}" />`);
return { programs: doc.getElementsByClassName('media-item') };
},
convertIntoEntry: function (program) {
var mediaData = program.getElementsByClassName('media-data')[0];
var title = mediaData.title;
var url = mediaData.href;
var viewers = program.querySelector('.item-viewers strong');
return new Program(url, title, {
icon: program.querySelector('.channel-tbn img').src,
visitors: viewers && Number.parseInt(viewers.textContent),
community: {
name: title,
url: url,
},
});
},
}), new Service({
id: 'youtube-live',
name: _('Youtube ライブ'),
url: 'https://www.youtube.com/live',
icon: 'https://i.ytimg.com/i/4R8DWoMoI7CAwX8_LjQHig/mq1.jpg',
disabledOr: true,
getDetails: function (word) {
var query = word.plus.join(' ');
if (word.minus.length > 0) {
query += ' -' + word.minus.join(' -')
}
return {
mode: 'no-cors',
method: 'GET',
url: 'https://www.youtube.com/results?filters=live&search_sort=video_date_uploaded&search_query='
+ encodeURIComponent(query),
responseType: 'document',
};
},
parseResponse: function (response) {
return { programs: response.getElementsByClassName('yt-lockup-dismissable') };
},
convertIntoEntry: function (program) {
var anchor = program.querySelector('.yt-lockup-title a');
var userAnchor = program.querySelector('.yt-lockup-byline a');
var description = program.getElementsByClassName('yt-lockup-description')[0];
var metaInfo = document.getElementsByClassName('yt-lockup-meta-info')[0];
return new Program(anchor.href, anchor.title, {
icon: program.querySelector('.yt-thumb img').src,
author: {
name: userAnchor.text,
url: userAnchor.href,
},
summary: description ? description.textContent : null,
visitors: metaInfo ? Number.parseInt(/[0-9]+/.exec(metaInfo)[0]) : null,
});
},
}), new Service({
id: 'younow',
name: _('YouNow'),
url: 'https://www.younow.com/',
disabledMinus: true,
/**
* 検索結果の最大件数。
* @constant {number}
*/
MAX_RESULT_LENGTH: 100,
getDetails: function (words) {
return {
mode: 'cors',
method: 'POST',
url: 'https://qz0xcgubgq.algolia.io/1/indexes/*/queries',
responseType: 'json',
headers: {
'X-Algolia-Application-Id': 'QZ0XCGUBGQ',
'X-Algolia-API-Key': '7f270d4586d986ef69fb5bab5ecd7f741b5cb3f7042881112ed46c97b5e8404a',
'X-Algolia-TagFilters': '(public)',
},
data: { requests: words.map(searchCriteria => {
return {
indexName: 'people_search_live',
params: `hitsPerPage=${this.MAX_RESULT_LENGTH}&advancedSyntax=1&query=${encodeURIComponent(searchCriteria.plus.join(' '))}`,
};
}) },
};
},
parseResponse: function (response, details, words) {
return { programs: Array.prototype.concat.apply([], response.results.map(function (result, index) {
var programs = [];
var searchCriteria = words[index];
for (var program of result.hits) {
if (program.tag === '') {
break;
}
program.searchCriteria = searchCriteria;
programs.push(program);
}
return programs;
})) };
},
convertIntoEntry: function (program) {
var url = 'https://www.younow.com/' + program.profile;
return new Program(url, '#' + program.tag, {
icon: 'https://cdn2.younow.com/php/api/channel/getImage/channelId=' + program.objectID,
author: {
name: program.firstName + ' ' + program.lastName,
url: url,
},
categories: [program.tag],
summary: program.description,
}, program.searchCriteria);
},
}), new Service({
id: 'livestream',
name: _('Livestream'),
url: 'https://livestream.com/watch/',
icon: 'https://cdn.livestream.com/website/794776b/assets/favicon-80e0433a0ae645d507841ae46338238a.ico',
/**
* 検索結果の最大件数。
* @constant {number}
*/
MAX_RESULT_LENGTH: 1000,
getDetails: function (words) {
return {
mode: 'cors',
method: 'POST',
url: 'https://7kjecl120u-1.algolia.io/1/indexes/*/queries',
responseType: 'json',
data: {
apiKey: '98f12273997c31eab6cfbfbe64f99d92',
appID: '7KJECL120U',
requests: words.map(searchCriteria => {
var query = searchCriteria.plus.join(' ');
if (searchCriteria.minus.length > 0) {
query += ' -' + searchCriteria.minus.join(' -')
}
return {
indexName: 'events',
params: `hitsPerPage=${this.MAX_RESULT_LENGTH}&advancedSyntax=1&facets=*&facetFilters=%5B%22is_live%3A1%22%5D&query=${encodeURIComponent(query)}`,
};
}),
},
};
},
parseResponse: function (response, details, words) {
return { programs: Array.prototype.concat.apply([], response.results.map(function (result, index) {
var searchCriteria = words[index];
for (var program of result.hits) {
program.searchCriteria = searchCriteria;
}
return result.hits;
})) };
},
convertIntoEntry: function (program) {
var tags = [];
if (program.category_name !== 'No category') {
tags.push(program.category_name);
}
if (program.subcategory_name) {
tags.push(program.subcategory_name);
}
if (program.tags) {
tags.concat(program.tags.split(','));
}
return new Program('https://livestream.com' + program.path, program.full_name, {
icon: program.owner_logo ? program.owner_logo.thumbnail.url : null,
private: Boolean(program.is_password_protected),
published: new Date(program.start_time),
author: {
name: program.owner_account_full_name,
url: 'https://livestream.com/accounts/' + program.owner_account_id,
},
categories: tags.length > 0 ? tags : null,
visitors: program.concurrent_viewers_count,
comments: program.live_video_post_comments_count,
}, program.searchCriteria);
},
}), new Service({
id: 'livetube',
name: _('Livetube'),
url: 'https://livetube.cc/',
disabledSearch: true,
getDetails: function () {
return {
mode: 'no-cors',
method: 'GET',
url: 'https://livetube.cc/index.live.json',
responseType: 'json',
};
},
parseResponse: function (response) {
return { programs: response };
},
convertIntoEntry: function (program) {
return new Program('https://livetube.cc/' + program.link, program.title, {
published: new Date(program.created),
author: {
name: program.author,
url: 'https://livetube.cc/hina0083/' + encodeURIComponent(program.author),
},
categories: program.tags,
visitors: program.view,
comments: program.comments,
});
},
})];
/**
* ライブ配信番組を表示する表に関する処理を行うユーティリティークラス。
*/
window.TableProcessor = {
/**
* 経過時間を更新する間隔。ミリ秒数。
* @type {number}
*/
DURATION_UPDATING_INTERVAL: 20 * DateUtils.SECONDS_TO_MILISECONDS,
/**
* 経過時間の更新を開始します。
*/
startUpdatingDurations: function () {
for (var duration of document.querySelectorAll('[itemtype="http://schema.org/VideoObject"] [itemprop="duration"]:not([hidden])')) {
var serialized = DateUtils.getDuration(new Date(duration.dataset.start));
duration.value = serialized.dateTime;
duration.textContent = serialized.text;
}
window.setTimeout(this.startUpdatingDurations.bind(this), this.DURATION_UPDATING_INTERVAL);
},
/**
* プライベート番組を表から取り除きます。
* @returns {HTMLRowElement[]}
*/
removePrivatePrograms: function () {
for (var requiresSubscription of document.querySelectorAll('[itemtype="http://schema.org/VideoObject"] [itemprop="requiresSubscription"][value="true"]')) {
requiresSubscription.closest('[itemscope]').remove();
}
Alert.showHits();
},
/**
* 指定されたサービスの番組を表から取り除きます。
* @param {Service} service
* @returns {HTMLRowElement[]}
*/
removeProgramsWithService: function (service) {
for (var row of this.getProgramsWithService(service)) {
row.remove();
}
Alert.showHits();
},
/**
* 指定されたサービスの番組を取得します。
* @param {Service} service
* @param {SearchCriteria} [searchCriteria] - OR検索ができないサービスにおいて、対象の検索条件。
* @returns {HTMLRowElement[]}
*/
getProgramsWithService: function (service, searchCriteria) {
return Array.from(document.querySelectorAll(
'[itemtype="http://schema.org/VideoObject"] [itemprop="provider"]' + (searchCriteria ? `[data-search-criteria="${CSS.escape(JSON.stringify(searchCriteria))}"]` : '') + ` [itemprop="url"][href="${service.url}"]`
)).map(providerURL => providerURL.closest('[itemtype="http://schema.org/VideoObject"]'));
},
/**
* 除外対象の番組を表から取り除きます。
*/
removeExclusions: function () {
for (var urlProperty of document.querySelectorAll('[itemtype="http://schema.org/VideoObject"] [itemprop="url"], [itemtype="http://schema.org/VideoObject"] [itemprop="workLocation"]')) {
if (UserSettings.exclusions.indexOf(urlProperty.href) !== -1) {
var row = urlProperty.closest('[itemtype="http://schema.org/VideoObject"]');
if (row.parentElement) {
row.remove();
}
}
}
Alert.showHits();
},
/**
* 以前に取得した番組を表から取り除きます。
* @param {Service} service - 対象のサービス。
* @param {Program[]} currentPrograms - 今回取得した番組。
* @param {SearchCriteria} [searchCriteria] - OR検索ができないサービスにおいて、対象の検索条件。
*/
removeOldPrograms: function (service, currentPrograms, searchCriteria) {
var urls = currentPrograms.map(program => program.link);
for (var row of this.getProgramsWithService(service, searchCriteria)) {
if (urls.indexOf(row.querySelector('td > [itemprop="url"]').href) === -1) {
row.remove();
}
}
},
/**
* 指定された番組を表に追加します。
* @param {Program} program
* @param {string[]} urls - 番組、ユーザー、コミュニティのURL。
*/
insertProgram: function (program, urls) {
var table = document.getElementById('programs');
var anchor = table.querySelector(urls.map(url => `[href="${CSS.escape(program.link)}"]`).join(','));
var previousRow;
if (anchor) {
// すでに同じ番組、または同じユーザーの番組、同じコミュニティの番組が追加されていれば
previousRow = anchor.closest('[itemtype="http://schema.org/VideoObject"]');
}
var row = this.convertProgramToRow(program, previousRow);
var tBody = table.tBodies[0];
tBody.insertBefore(row, tBody.rows[this.getInsertPosition(table, row)]);
},
/**
* 指定された番組を表すtr要素を返します。
* @param {Program} program
* @param {HTMLTableRowElement} [previousRow] - 指定されていれば、その行を更新します。
* @return {HTMLTableRowElement}
*/
convertProgramToRow: function (program, previousRow) {
var row = previousRow || document.querySelector('#programs template').content.firstElementChild.cloneNode(true);
// サービス
if (!previousRow) {
var provider = row.querySelector('[itemprop="provider"]');
provider.querySelector('[itemprop="name"]').value = program.service.name;
provider.querySelector('[itemprop="url"]').href = program.service.url;
var logo = provider.querySelector('[itemprop="logo"]');
logo.src = program.service.icon;
logo.alt = program.service.name;
logo.title = program.service.name;
if (program.service.disabledOr) {
provider.dataset.searchCriteria = JSON.stringify(program.searchCriteria);
}
}
// アイコン
var image = row.querySelector('[itemprop="image"]');
if (program.icon) {
if (!previousRow || program.icon !== image.src) {
image.src = program.icon;
image.hidden = false;
}
} else {
image.hidden = true;
}
// コミュ限
var requiresSubscription = row.querySelector('[itemprop="requiresSubscription"]');
if (program.private) {
if (!previousRow || requiresSubscription.value === 'false') {
requiresSubscription.value = 'true';
requiresSubscription.textContent = _('限定公開');
}
} else {
if (previousRow && requiresSubscription.value === 'true') {
requiresSubscription.value = 'false';
requiresSubscription.textContent = '';
}
}
// 経過時間
var duration = row.querySelector('[itemprop="duration"]');
if (program.published) {
if (!previousRow || program.published.toISOString() !== duration.dataset.start) {
var serialized = DateUtils.getDuration(program.published);
duration.dateTime = serialized.dateTime;
duration.textContent = serialized.text;
duration.dataset.start = program.published.toISOString();
duration.hidden = false;
}
}
// タイトル
var name = row.querySelector('td:not([itemprop="provider"]) > [itemprop="name"]');
if (!previousRow || program.title !== name.value) {
name.value = program.title;
this.setMarkedText(name.firstElementChild, program.title, program.searchCriteria || UserSettings.words);
}
// タグ
var keywords = row.querySelector('[itemprop="keywords"]');
var tags = program.categories ? program.categories.join(',') : '';
if (tags !== keywords.value) {
keywords.value = tags;
this.setMarkedText(keywords, program.categories ? program.categories.join(' ') : '', program.searchCriteria || UserSettings.words);
}
// 配信者
var author = row.querySelector('[itemprop="author"]');
if (program.author) {
var name = author.querySelector('[itemprop="name"]');
var workLocation = author.querySelector('[itemprop="workLocation"]');
if (!previousRow || program.author.name !== name.value
|| program.author.url && program.author.url !== workLocation.href) {
name.value = program.author.name;
this.setMarkedText(workLocation, program.author.name);
if (program.author.url) {
workLocation.href = program.author.url;
}
}
}
// 説明文
var description = row.querySelector('[itemprop="description"]');
if (program.summary && !(previousRow && program.summary === description.value)) {
description.value = program.summary;
this.setMarkedText(description, program.summary, program.searchCriteria || UserSettings.words);
}
// 累計来場者数
var userInteractionCount = row.querySelector('[itemprop="userInteractionCount"]');
if (typeof program.visitors === 'number') {
userInteractionCount.value = program.visitors;
userInteractionCount.textContent = _('%d 人').replace('%d', program.visitors);
}
// コメント数
var commentCount = row.querySelector('[itemprop="commentCount"]');
if (typeof program.comments === 'number') {
commentCount.value = program.comments;
commentCount.textContent = _('%d コメ').replace('%d', program.comments);
}
// コミュニティ
var productionCompany = row.querySelector('[itemprop="productionCompany"]');
if (program.community) {
productionCompany.querySelector('[itemprop="name"]').value = program.community.name;
var url = productionCompany.querySelector('[itemprop="url"]');
this.setMarkedText(url, program.community.name, program.searchCriteria || UserSettings.words);
if (program.community.url) {
url.href = program.community.url;
}
}
// リンク
for (var url of row.querySelectorAll('td > [itemprop="url"], td:not([itemscope]) > [itemprop="name"] > [itemprop="url"]')) {
url.href = program.link;
}
return row;
},
/**
* 検索対象だった文字列を記入します。
* @param {HTMLElement} target
* @param {string} str
* @param {(SearchCriteria|SearchCriteria[])} [searchCriteria] - 検索対象の項目でない (文字数制限のみを行う) 場合は省略。
*/
setMarkedText: function (target, str, searchCriteria) {
/**
* 昇順に並んでいる、重なる部分が無い [先頭のオフセット, 末尾のオフセット] の配列。
* @type {number[][]}
*/
var offsets = searchCriteria ? WordProcessor.getMatches(str, searchCriteria) : [];
/**
* 挿入するTextノード。
* @type {Text}
*/
var text = new Text(str);
/**
* mark要素に内包する範囲。
* @type {Range[]}
*/
var ranges = WordProcessor.convertOffsetsToRanges(text, offsets);
if (str.length > UserSettings.MAX_VISIBLE_CHARACTERS
&& document.getElementsByName('ellipsisTooLongRSSData')[0].checked) {
// 文字数が制限を超えており、表示文字数の制限が有効なら
DOMTokenList.prototype.add.apply(
target.classList,
WordProcessor.extractTextNode(text, offsets, UserSettings.MAX_VISIBLE_CHARACTERS, UserSettings.MAX_BEFORE_CHARACTERS)
);
var title = WordProcessor.markMatchesAsPlainText(str, offsets);
if (/\(Windows .+ Chrome\/.+(?!Edge\/)/.test(window.navigator.userAgent)) {
// Windows 版の Opera、Google Chrome におけるフリーズの回避
target.dataset.title = title;
} else {
target.title = title;
}
} else {
delete target.dataset.title;
target.removeAttribute('title');
}
while (target.hasChildNodes()) {
target.firstChild.remove();
}
target.appendChild(text);
for (var range of ranges) {
range.surroundContents(document.createElement('mark'));
}
},
/**
* 指定された列が昇順に並んでいれば「asc」、降順に並んでいれば「desc」を返します。
* @param {HTMLTableHeaderCellElement} sortedTH
*/
getSorting: function (sortedTH) {
return sortedTH.getAttribute('sorted').toLowerCase().trim().split(/\s+/).indexOf('reversed') !== -1
? 'desc'
: 'asc';
},
/**
* 指定された列をキーに行を並べ替えます。
* @see [HTML Standard]{@link https://html.spec.whatwg.org/multipage/tables.html#table-sorting-algorithm}
* @param {HTMLTableHeaderCellElement} th
*/
sort: function (th) {
var table = th.closest('table');
var event = new Event('sort', {
cancelable: true,
});
table.dispatchEvent(event);
if (!event.defaultPrevented) {
/**
* すでに並び替えられている列。
* @type {HTMLTableHeaderCellElement}
*/
var sortedTH = th.parentElement.querySelector('[sorted]');
/**
* 並べ替え後、降順になるなら真。
* @type {boolean}
*/
var reversed = sortedTH === th && this.getSorting(sortedTH) === 'asc';
// 行リストを配列化
var tBody = table.tBodies[0];
var rows = Array.from(tBody.rows);
if (sortedTH === th) {
// 選択された列が、すでに並べ替えられている列なら
// sorted属性の設定
sortedTH.setAttribute('sorted', (reversed ? 'reversed ' : '') + '1');
// 並び順を反転
rows.reverse();
} else {
// 他の列のsorted属性を削除
sortedTH.removeAttribute('sorted');
// sorted属性の設定
th.setAttribute('sorted', '1');
// 昇順に並び替え
rows.sort((a, b) => this.compareRows(th, a, b));
}
// 画面に反映
tBody.removeAttribute('aria-live');
for (var row of rows) {
tBody.appendChild(row);
}
window.setTimeout(function () {
tBody.setAttribute('aria-live', 'polite');
}, 0);
}
},
/**
* 行の挿入位置を取得します。
* @param {HTMLTableElement} table
* @param {HTMLTableRowElement} row
* @returns {number} 0から始まるインデックス。
*/
getInsertPosition: function (table, row) {
var insertingColumn = table.querySelector('[sorted]');
var reversed = this.getSorting(insertingColumn) === 'desc';
var rows = table.tBodies[0].rows;
var insertPosition = rows.length;
for (var comparisonRow of Array.from(rows)) {
var result = this.compareRows(insertingColumn, comparisonRow, row);
if (reversed ? result < 0 : 0 < result) {
insertPosition = comparisonRow.sectionRowIndex;
break;
}
}
return insertPosition;
},
/**
* {@link Array#sort}の比較関数内で用いる、行と行を比較する関数
* @param {HTMLTableHeaderCellElement} th - キーとなるセル。
* @param {HTMLTableRowElement} a
* @param {HTMLTableRowElement} b
* @returns {number} a < b なら -1、a > b なら 1 を返す
*/
compareRows: function (th, a, b) {
return this.getCellValue(th, a) < this.getCellValue(th, b) ? -1 : 1;
},
/**
* セルの値を取得します。
* @param {HTMLTableHeaderCellElement} th - キーとなるセル。
* @param {HTMLTableRowElement} row
* @returns {string}
*/
getCellValue: function (th, row) {
var value;
var cell = row.cells[th.cellIndex];
var child = cell.firstElementChild;
switch (child && child.localName) {
case 'data':
value = child.value;
if (/^[0-9]+$/.test(value)) {
value = Number.parseInt(value);
}
break;
case 'time':
value = DateUtils.parseDurationString(child.dateTime);
break;
default:
value = cell.textContent;
}
return value;
},
/**
* ステータス行を除くすべての行を取得します。
* @param {HTMLTableElement} table
* @returns {HTMLTableRowElement[]}
* @access private
*/
getRows: function (table) {
var rows = Array.from(table.querySelectorAll(':not(tfoot) > tr'));
rows.push(table.getElementsByTagName('template')[0].content.firstElementChild);
return rows;
},
/**
* 列 th を列 refTH の前に移動します。
* @param {HTMLTableHeaderCellElement} th
* @param {?(HTMLTableHeaderCellElement|number)} refTH - null の場合、末尾に移動します。
*/
moveColumn: function (th, refTH) {
var targetIndex = th.cellIndex;
var refIndex = typeof refTH === 'number' ? refTH : (refTH ? refTH.cellIndex : -1);
for (var tr of this.getRows(th.closest('table'))) {
tr.insertBefore(tr.cells[targetIndex], tr.cells[refIndex]);
}
// 表示する列の設定項目の並び替え
var ul = document.getElementById('visible-columns');
ul.insertBefore(ul.querySelector(`[value="${th.id}"]`).closest('li'), ul.children[refIndex]);
},
/**
* 列 th を隠します。
* @param {HTMLTableHeaderCellElement} th
*/
hideColumn: function (th) {
if (th.getAttribute('aria-hidden') !== 'true') {
var targetIndex = th.cellIndex;
for (var tr of this.getRows(th.closest('table'))) {
tr.cells[targetIndex].setAttribute('aria-hidden', 'true');
}
}
},
/**
* 列 th を表示します。
* @param {HTMLTableHeaderCellElement} th
*/
showColumn: function (th) {
if (th.getAttribute('aria-hidden') === 'true') {
var targetIndex = th.cellIndex;
for (var tr of this.getRows(th.closest('table'))) {
tr.cells[targetIndex].removeAttribute('aria-hidden');
}
}
},
/**
* 列の順番を取得します。
* @return {string[]}
*/
getColumnPositions: function () {
return Array.from(document.querySelectorAll('#programs th')).map(function (th) {
return th.id;
});
},
/**
* 列の移動先を示すクラス名を削除します。
*/
removeOldClassName: function () {
var oldRef = document.querySelector('.inserting-before, .inserting-after');
if (oldRef) {
oldRef.classList.remove('inserting-before', 'inserting-after');
}
},
/**
* 列の順番を反映します。
* @param {?string} version
* @param {string[]} columnPositions - 指定されなかった列は末尾に並びます。
*/
reflectColumnPositions: function (version, columnPositions) {
if (!version) {
// 5.0.0 より前のバージョンの設定であれば
columnPositions.unshift('service');
GM_setValue('columns-position', JSON.stringify(columnPositions));
}
var tBody = document.querySelector('#programs tbody');
tBody.removeAttribute('aria-live');
var i = 0;
for (var column of columnPositions) {
this.moveColumn(document.getElementById(column), i);
i++;
}
window.setTimeout(function () {
tBody.setAttribute('aria-live', 'polite');
}, 0);
},
}
/**
* ユーザー設定値、およびその変更に関するメソッド、プロパティ。
*/
window.UserSettings = {
/**
* 省略設定が有効の時に、一項目で表示する最大の符号単位数。
* @constant {number}
*/
MAX_VISIBLE_CHARACTERS: 60,
/**
* 表示を省略した際に、ヒットした文字列より前に表示する最大の符号単位数。
* @constant {number}
*/
MAX_BEFORE_CHARACTERS: 3,
/**
* 表示を省略した際に、ヒットした文字列より前に表示する最大の符号単位数。
* @constant {number}
*/
MAX_BEFORE_CHARACTERS: 3,
/**
* 検索条件。
* @type {SearchCriteria[]}
*/
words: [],
/**
* 検索から除外するユーザー、コミュニティ、チャンネルのURL。exclusionsFromExternalを含む。
* @type {string[]}
*/
exclusions: [],
/**
* ユーザー設定値 NGsURI から取得した検索から除外するユーザー、コミュニティ、チャンネルのURL。
* @type {string[]}
*/
exclusionsFromExternal: [],
/**
* ユーザー設定値をJSONファイルにエクスポートします。
*/
export: function () {
var exportedValues = {};
for (var key in UserSettings.schema.properties) {
var property = UserSettings.schema.properties[key];
var value;
switch (property.type) {
case 'string':
case 'integer':
case 'boolean':
value = key === 'audioData' ? UserSettings.getLargeValue(key) : GM_getValue(key);
break;
case 'number':
value = GM_getValue(key);
if (value !== undefined && value !== null) {
value = Number.parseFloat(value);
}
break;
case 'array':
case 'object':
value = GM_getValue(key);
if (value !== undefined && value !== null) {
value = JSON.parse(value);
}
break;
}
if (value !== undefined && value !== null) {
exportedValues[key] = value;
}
}
document.body.insertAdjacentHTML(
'beforeend',
h`<a href="${window.URL.createFor(new Blob([JSON.stringify(exportedValues, null, '\t')],{ type: 'application/json' }))}" download="${Alert.ID + '.json'}" hidden=""></a>`
);
var anchor = document.body.lastElementChild;
anchor.click();
anchor.remove();
},
/**
* ファイルのインポート用に生成したinput要素を取り除くまでのミリ秒数。
* @constant {number}
*/
MAX_LIFETIME: 10 * DateUtils.MINUTES_TO_MILISECONDS,
/**
* ユーザー設定値をJSONファイルからインポートします。
*/
import: function () {
document.body.insertAdjacentHTML(
'beforeend',
h`<input type="file" accept=".json,application/json" hidden="" />`
);
var input = document.body.lastElementChild;
input.addEventListener('change', function parse(event) {
event.target.removeEventListener(event.type, parse);
var file = event.target.files[0];
if (file) {
var reader = new FileReader();
reader.addEventListener('load', function (event) {
var result;
try {
result = JSON.parse(event.target.result);
} catch (error) {
window.alert(_('インポートに失敗しました。\n\nエラーメッセージ:\n%s').replace('%s', error));
}
if (result !== undefined) {
if (Object.prototype.toString.call(result) === '[object Object]') {
for (var key in result) {
if (result[key] === null) {
delete result[key];
}
}
}
var validate = jsen(UserSettings.schema, { greedy: true });
if (validate(result)) {
// 検索語句
document.getElementById('searching-words').value
= result.words ? result.words.join('\n') + '\n' : '';
document.getElementsByName('save-searching-words')[0].click();
// 除外リスト
document.getElementById('ng-communities').value
= result.NGs ? result.NGs.join('\n') + '\n' : '';
document.getElementsByName('save-ng-communities')[0].click();
// 外部の除外リストURL
if (result.NGsURI) {
GM_setValue('NGsURI', result.NGsURI);
} else {
GM_deleteValue('NGsURI');
}
// 行のソート
if (!result.order) {
result.order = UserSettings.schema.properties.order.default;
}
var sortedTH = document.querySelector('#programs [sorted]');
if (result.order.name !== sortedTH.id) {
sortedTh = document.getElementById(result.order.name);
sortedTh.click();
}
if (result.order.order !== TableProcessor.getSorting(sortedTH)) {
sortedTh.click();
}
// 列の位置
if (result['columns-position']) {
GM_setValue('columns-position', JSON.stringify(result['columns-position']));
TableProcessor.reflectColumnPositions(result.version, result['columns-position']);
} else {
GM_deleteValue('columns-position');
TableProcessor.reflectColumnPositions(GM_info.script.version, UserSettings.schema.properties['columns-position'].default);
}
// 表示される列
UserSettings.showColumns(result.version, result['visible-columns']
? result['visible-columns']
: UserSettings.schema.properties['visible-columns'].default);
// 検索対象のサービス
UserSettings.enableServices(result.version, result['target-services'] || serviceIds);
// プライベート配信・長い文字列の省略・言語
for (var key of ['exclusionMemberOnly', 'ellipsisTooLongRSSData', 'languageFilter']) {
var input = document.getElementsByName(key)[0];
if (input.checked !== (key in result ? result[key] : UserSettings.schema.properties[key].default)) {
input.click();
}
}
// ミュート
var alertTone = document.getElementById('alert-tone');
if (result.audioMuted) {
alertTone.muted = true;
}
// 音量
if ('audioVolume' in result) {
alertTone.volume = result.audioVolume;
}
// 音声ファイル
var deleteSound = document.getElementsByName('delete-sound')[0];
if (!deleteSound.hidden) {
deleteSound.click();
}
if (result.audioData) {
var audio = new Audio(result.audioData);
audio.addEventListener('loadeddata', function () {
if (audio.error) {
// ブラウザが再生できないデータなら
window.alert(_('使用中のブラウザが対応していないファイル形式のため、プロパティ %p を無視しました。').replace('%p', 'audioData'));
} else {
UserSettings.setAudioData(result.audioData);
}
});
audio.addEventListener('error', function () {
// ブラウザが再生できないデータなら
window.alert(_('使用中のブラウザが対応していないファイル形式のため、プロパティ %p を無視しました。').replace('%p', 'audioData'));
});
}
} else {
window.alert(_('インポートに失敗しました。\n\nエラーメッセージ:\n%s').replace('%s', JSON.stringify(validate.errors, null, '\t')));
}
}
});
reader.readAsText(file);
}
input.remove();
});
input.click();
window.setTimeout(function () {
if (input.parentNode) {
input.remove();
}
}, this.MAX_LIFETIME);
},
/**
* 音声ファイルを設定します。
* @param {string} audioData - data URL。
*/
setAudioData: function (audioData) {
try {
UserSettings.setLargeValue('audioData', audioData);
var alertTone = document.getElementById('alert-tone');
alertTone.src = audioData;
alertTone.hidden = false;
document.getElementsByName('delete-sound')[0].hidden = false;
} catch (error) {
if (error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
window.alert(_('ファイルサイズが大きいため、設定に失敗しました。\n\nエラーメッセージ:\n%s').replace('%s', e));
} else {
throw error;
}
}
},
/**
* 検索語句文字列を {@link SearchCriteria} に変換します。
* @param {string[]} words - 正規化済みの文字列。
* @returns {SearchCriteria[]}
*/
parseWords: function (words) {
return words.map(function (word) {
var searchCriteria = {
plus: [],
minus: [],
};
for (var value of StringProcessor.unifyCases(word).split(' ')) {
if (value.startsWith('-')) {
searchCriteria.minus.push(value.slice(1));
} else {
searchCriteria.plus.push(value);
}
}
return searchCriteria;
});
},
/**
* バージョン5.0.0より前の除外IDをURLにします。
* @param {string[]} exclusions
* @returns {string[]}
*/
parseExclusions: function (exclusions) {
return exclusions.map(this.parseExclusion).filter(url => url);
},
/**
* バージョン5.0.0より前の除外IDをURLにします。
* @param {string} exclusion
* @returns {?string}
*/
parseExclusion: function (exclusion) {
var url = null;
if (exclusion.startsWith('http')) {
url = exclusion;
} else {
var result = /(?:co|ch)[1-9][0-9]*/.exec(exclusion);
if (result) {
url = 'http://com.nicovideo.jp/community/' + result[0];
}
}
return url;
},
/**
* 検索対象のサービスを取得します。
* @return {string[]}
*/
getTargetServices: function () {
return Array.from(document.querySelectorAll('[name="target-services"]:checked')).map(checkbox => checkbox.value);
},
/**
* 表示中の列を取得します。
* @return {string[]}
*/
getShownColumns: function () {
return Array.from(document.querySelectorAll('[name="visible-columns"]:checked')).map(checkbox => checkbox.value);
},
/**
* 指定されたサービスを有効化し、それ以外を無効化します。
* @param {?string} version
* @param {string[]} services
*/
enableServices: function (version, services) {
for (var service of document.getElementsByName('target-services')) {
if ((services.indexOf(service.value) !== -1) !== service.checked) {
service.click();
}
}
},
/**
* 最後に検索結果の取得に成功にした日時を表示します。
* @param {Service} service
*/
showLatestUpdatedDate: function (service) {
var date = new Date();
var html = h`
<time datetime="${date.toISOString()}">
${date.toLocaleString()}
</time>
`;
document.querySelector(`[name="target-services"][value="${service.id}"]`).closest('tr').cells[1].innerHTML = html;
document.querySelector('#programs tfoot tr:nth-of-type(1) td').innerHTML = h(_('%s 更新')).replace('%s', html);
},
/**
* 直近の例外を表示します。
* @param {Service} service
* @param {Error} error
*/
showLatestError: function (service, error) {
var message = error.toString();
if ('lineNumber' in error) {
message += ` (${error.lineNumber}:${error.columnNumber})`
}
var html = h`<pre>${message}</pre>`;
document.querySelector(`[name="target-services"][value="${service.id}"]`).closest('tr').cells[2].innerHTML = html;
document.querySelector('#programs tfoot tr:nth-of-type(2) td').innerHTML = html;
},
/**
* 指定された列を表示し、それ以外を非表示にします。
* @param {?string} version
* @param {string[]} columns
*/
showColumns: function (version, columns) {
if (!version) {
// 5.0.0 より前のバージョンの設定であれば
columns.unshift('service');
if (columns.indexOf('category') === -1) {
columns.push('category');
}
GM_setValue('visible-columns', JSON.stringify(columns));
}
for (var column of document.getElementsByName('visible-columns')) {
if ((columns.indexOf(column.value) !== -1) !== column.checked) {
column.click();
}
}
},
/**
* Firefox 23 からの仕様変更 (Bug 836263) により、UserScriptLoader.uc.js において {@link GM_setValue} で1MiB以上の
* データを保存できなくなったため、容量制限を超過したデータはローカルストレージに保存します。
* @param {string} name
* @param {(string|number|boolean)} value
* @returns {string}
* @see [GM_setValue size exception(1 * 1024 * 1024) · Issue #1 · Constellation/ldrfullfeed · GitHub]{@link https://github.com/Constellation/ldrfullfeed/issues/1}
*/
setLargeValue: function (name, value) {
var error;
GM_setValue(name, value);
if (GM_getValue(name) !== value) {
// 値が正しく設定されていなければ
var item = this.getValuesFromLocalStorage();
item[name] = value;
window.localStorage.setItem(Alert.ID, JSON.stringify(item));
GM_deleteValue(name);
}
return value;
},
/**
* {@link UserSettings.setLargeValue} で保存したデータを取得します。
* @param {type} name
* @param {*} defaultValue
* @returns {*}
*/
getLargeValue: function (name, defaultValue) {
var value = GM_getValue(name);
if (value === undefined || value === null) {
var item = this.getValuesFromLocalStorage();
value = item[name];
}
return value === undefined ? defaultValue : value;
},
/**
* {@link UserSettings.setLargeValue} で保存したデータを削除します。
* @param {string} name
*/
deleteLargeValue: function (name) {
GM_deleteValue(name);
var item = this.getValuesFromLocalStorage();
delete item[name];
window.localStorage.setItem(Alert.ID, JSON.stringify(item));
},
/**
* {@link UserSettings.setLargeValue} {@link UserSettings.getLargeValue} {@link UserSettings.deleteLargeValue} から利用される、すべての設定値を取得する関数。
* @returns {Object.<(string|number|boolean)>}
* @acsess private
*/
getValuesFromLocalStorage: function () {
var item = window.localStorage.getItem(Alert.ID);
if (item) {
try {
item = JSON.parse(item);
} catch (e) {
item = {};
}
} else {
item = {};
}
return item;
}
};
/**
* 文字列の処理を行うユーティリティークラス。
*/
window.StringProcessor = {
/**
* ひらがなをカタカナに変換するときの加数。
* @constant {number}
*/
ADDEND_HIRAGANA_TO_KATAKANA: 'ァ'.charCodeAt() - 'ぁ'.charCodeAt(),
/**
* ひらがなをカタカナに変換します。
* @param {string} str
* @returns {string}
*/
convertToKatakana: function (str) {
return str.replace(
/[ぁ-ゖ]/g,
match => String.fromCharCode(match.charCodeAt() + this.ADDEND_HIRAGANA_TO_KATAKANA)
);
},
/**
* 正規化し、連続する空白文字を半角スペースに。
* @param {string} str
* @returns {string}
*/
normalize: function (str) {
return str.normalize('NFKC').replace(/\s+/g, ' ');
},
/**
* 文字種を統一します。
* @param {string} str - 正規化済みの文字列。
* @returns {string}
*/
unifyCases: function (normalizedStr) {
return this.convertToKatakana(normalizedStr).toLocaleLowerCase();
},
};
var WordProcessor = {
/**
* OR検索を行います。
* @param {string} str
* @param {SearchCriteria[]} words
* @return {?SearchCriteria}
*/
orSearch: function (str, words) {
var searchCriteria = null;
for (var word of words) {
if (this.andSearch(str, word)) {
searchCriteria = word;
break;
}
}
return searchCriteria;
},
/**
* AND検索を行います。
* @param {string} str
* @param {SearchCriteria} word
* @return {boolean}
*/
andSearch: function (str, word) {
return this.minusSearch(str, word.minus) && word.plus.every(plus => p(str).includes(plus));
},
/**
* マイナス検索を行います。
* @param {string} str
* @param {string[]} minusWord
* @return {boolean} str に minusWord のどの文字列も含まれていなければ真。
*/
minusSearch: function (str, minusWord) {
return !minusWord.some(minus => p(str).includes(minus));
},
/**
* 検索語句が含まれる位置を取得します。
* @param {string} data
* @param {(SearchCriteria|SearchCriteria[])} searchCriteria
* @returns {number[][]} [先頭のオフセット, 末尾のオフセット] の配列。重なる部分は一つにまとめます。
*/
getMatches: function (data, searchCriteria) {
var words = Array.isArray(searchCriteria)
? searchCriteria.reduce((words, searchCriteria) => words.concat(searchCriteria.plus), [])
: searchCriteria.plus;
var unified = StringProcessor.unifyCases(data);
var offsets = [];
for (var word of words) {
var index = unified.indexOf(word);
if (index !== -1) {
offsets.push([index, index + word.length]);
}
}
// ソート
offsets.sort((a, b) => a[0] - b[0]);
// 位置が重なっていたら一つにまとめる
for (var i = 0, l = offsets.length; i < l; i++) {
if (offsets[i + 1] && offsets[i][1] >= offsets[i + 1][0]) {
// 次の位置のペアが存在し、現在のペアの終了位置が次のペアの開始位置以上であれば
// 現在のペアの終了位置を次のペアの終了位置に
offsets[i][1] = offsets[i + 1][1];
// 次のペアを削除
offsets.splice(i + 1, 1);
// 次の次のペアと重なっているかも確認
i--;
}
}
return offsets;
},
/**
* 指定された箇所の範囲を作成します。
* @param {Text} text
* @param {number[][]} offsets - 重なる部分が無い [先頭のオフセット, 末尾のオフセット] の配列。
* @returns {(Range[]|Text)}
*/
convertOffsetsToRanges: function (text, offsets) {
return offsets.map(function (offset) {
var range = new Range();
range.setStart(text, offset[0]);
range.setEnd(text, offset[1]);
return range;
});
},
/**
* 指定された部分を括弧で囲みます。
* @param {string} data
* @param {number[][]} offsets - 昇順に並んでいる、重なる部分が無い [先頭のオフセット, 末尾のオフセット] の配列。
* @returns {string}
*/
markMatchesAsPlainText: function (data, offsets) {
return offsets.concat().reverse().reduce(function (data, offset) {
return data.slice(0, offset[0])
+ _(' ❰❰%s❱❱ ').replace('%s', data.slice(offset[0], offset[1]))
+ data.slice(offset[1]);
}, data);
},
/**
* 指定された範囲が含まれるようにTextノードを切り出します。
* @param {Text} text
* @param {number[][]} offsets - 昇順に並んでいる、重なる部分が無い [先頭のオフセット, 末尾のオフセット] の配列。
* @param {number} maxLength - 表示する最大の符号単位数。切り取ったときにサロゲートペアが壊れるようであれば、1、2文字増やします。
* @param {number} beforeLength - 一致箇所より前に表示する符号単位数。切り取ったときにサロゲートペアが壊れるようであれば、1文字増やします。
* @returns {string[]} 先頭が切り取られていれば「ellipsis-left」、末尾が切り取られていれば「ellipsis-right」を含む配列。
*/
extractTextNode: function (text, offsets, maxLength, beforeLength) {
/**
* 切り取り範囲。
* @type {Range[]}
*/
var trimRanges = [];
/**
* 切り取る前の文字列。
* @type {number}
*/
var data = text.data;
/**
* 切り取る前の文字列の符号単位数。
* @type {number}
*/
var dataLength = text.length;
/**
* 戻り値。
* @type {string[]}
*/
var classes = [];
/**
* 表示する部分の終了位置。
* @type {number}
*/
var viewEndOffset;
if (offsets.length > 0) {
// 検索語句が一致する箇所があれば
/**
* 表示する部分の開始位置。
* @type {number}
*/
var viewStartOffset;
if (offsets[offsets.length - 1][1] <= maxLength) {
// 先頭から制限文字数の範囲内にマーク位置がすべて含まれていれば
viewStartOffset = 0;
viewEndOffset = maxLength;
} else {
viewStartOffset = offsets[0][0] - beforeLength;
viewEndOffset = viewStartOffset + maxLength;
if (viewStartOffset < 0) {
// 表示する部分の開始位置が先頭を超えていれば
viewStartOffset = 0;
}
if (viewEndOffset >= dataLength) {
// 表示する部分の終了位置が末尾を超えていれば
// 終了位置を末尾に
viewEndOffset = dataLength;
// 開始位置を終了位置から最大文字数分引いた位置に
viewStartOffset = viewEndOffset - maxLength;
}
}
if (viewStartOffset > 0) {
// 表示部分の開始位置が先頭より後ろなら
var charCode = data.charCodeAt(viewStartOffset);
if (0xDC00 <= charCode && charCode <= 0xDFFF) {
// 表示部分の先頭文字が下位サロゲートであれば
viewStartOffset--;
}
if (viewStartOffset > 0) {
var range = new Range();
range.setStart(text, 0);
range.setEnd(text, viewStartOffset);
trimRanges.push(range);
classes.push('ellipsis-left');
}
}
} else {
viewEndOffset = maxLength;
}
if (viewEndOffset < dataLength) {
// 表示部分の終了位置が末尾より前なら
var charCode = data.charCodeAt(viewEndOffset - 1);
if (0xD800 <= charCode && charCode <= 0xDBFF) {
// 表示部分の末尾文字が上位サロゲートであれば
viewEndOffset++;
}
if (viewEndOffset < dataLength) {
var range = new Range();
range.setStart(text, viewEndOffset);
range.setEnd(text, dataLength);
trimRanges.push(range);
classes.push('ellipsis-right');
}
}
// 切り取る
for (var range of trimRanges) {
range.deleteContents();
};
return classes;
},
};
/**
* AND検索を行う検索語句。
* @typedef {Object} SearchCriteria
* @property {string} plus - AND検索を行うキーワード。
* @property {string} minus - マイナス検索を行うキーワード。
*/
/**
* 検索語句にヒットする番組が見つかったとき。
* @event Service#ProgramEvent
* @type {CustomEvent}
* @property {string} type - 「progress」を返す。
* @property {Program} detail - ライブ配信番組。
*/
/**
* 最後のページを取得し終えたとき。OR検索できないサービスの場合は、一度の検索で複数回送出される。
* @event Service#LoadedEvent:load
* @type {CustomEvent}
* @property {string} type - 「load」を返す。
* @property {Object} detail
* @property {Service} detail.service - 対象のサービス。
* @property {Program[]} detail.programs - 今回の検索でヒットしたライブ配信番組。OR検索できないサービスの場合は、一つの検索条件にヒットした番組のみ。
* @property {SearchCriteria} [detail.searchCriteria] - OR検索ができないサービスにおいて、対象の検索条件。
*/
/**
* 配信の取得に必要な情報を返す。
* @callback getDetails
* @param {(SearchCriteria|SearchCriteria[])} [words] - OR検索が可能なら配列となる。
* @return {HTTPRequestInit}
*/
/**
* 取得した情報から番組を取り出す。
* @callback parseResponse
* @param {(Object|string)} response
* @param {HTTPRequestInit} details
* @param {SearchCriteria[]} [words]
* @return {(Programs|Error)} 取得に失敗している場合は例外を返す。
*/
/**
* 取得した番組の一覧。
* @typedef {Object} Programs
* @property {(Object[]|NodeList|HTMLCollection)} programs - サイト独自形式の番組情報の配列。
* @property {HTTPRequestInit} [next] - 結果が複数ページにわたる場合に、次のページが存在すれば指定。
*/
/**
* サイト独自形式の番組情報を {@link Program} に変換する関数。
* @callback convertIntoEntry
* @property {Object} programs - サイト独自形式の番組情報。
* @returns {Program}
*/
/**
* ライブ配信サービス。
* @class
* @augments EventTarget
* @param {Object} details
* @param {string} details.id - サービスを識別するID。
* @param {string} details.name - サイト名。
* @param {string} details.url - サイトのURL。
* @param {string} [details.icon] - サイトアイコンのURL。サイトのURLがホストとスラッシュで終わり、アイコンが /favicon.ico に配置されている場合は省略可。
* @param {getDetails} details.getDetails - 番組の取得に必要な情報を返す。
* @param {parseResponse} details.parseResponse - 取得した情報から番組を取り出す。
* @param {boolean} [details.disabledSearch] - 全件取得が可能な (検索ができない) サービスなら真。
* @param {boolean} [details.disabledOr] - OR検索ができないサービスなら真。
* @param {boolean} [details.disabledMinus] - マイナス検索ができないサービスなら真。
* @param {boolean} [details.disabledLanguageFilter] - 言語の絞り込み検索ができないサービスなら真。
* @param {convertIntoEntry} details.convertIntoEntry - 第1引数のサイト独自形式の番組情報を {@link Program} に変換する関数。
* @param {number} [details.delay=360000] - 情報を取得する間隔。ミリ秒。OR検索できないサービスの場合、各単語の検索間隔。既定値は6分。
* @param {number} [details.pagingDelay=10000] - 結果が複数ページにわたる場合に、次のページを取得するまでの間隔。ミリ秒。既定値は10秒。
* @global
*/
function Service (details) {
/**
* 情報を取得する間隔の既定値。ミリ秒。
* @constant {number}
* @access private
*/
this.DEFAULT_DELAY = 6 * DateUtils.MINUTES_TO_MILISECONDS;
/**
* 結果が複数ページにわたる場合に、次のページを取得するまでの間隔の既定値。ミリ秒。
* @constant {number}
* @access private
*/
this.DEFAULT_PAGING_DELAY = 1 * DateUtils.SECONDS_TO_MILISECONDS;
/**
* サービスを識別するID。
* @type {string}
* @readonly
*/
this.id = details.id;
/**
* サイト名。
* @type {string}
* @readonly
*/
this.name = details.name;
/**
* サイトのURL。
* @type {string}
* @readonly
*/
this.url = details.url;
/**
* サイトアイコンのURL。
* @type {string}
* @readonly
*/
this.icon = details.icon || details.url + 'favicon.ico';
/**
* OR検索ができないサービスについて、検索語句の0から始まるインデックス。
* @type {number}
* @access private
*/
this.wordIndex = 0;
/**
* 検索対象のサービスなら真。
* @type {boolean}
* @access private
*/
this.enabled = false;
/**
* abort() メソッドを持つオブジェクト。
* @type {HTTPRequest}
* @access private
*/
this.request;
/**
* タイマーID。
* @type {number}
* @access private
*/
this.timer;
/**
* 現在の検索で取得した番組。
* @type {Program[]}
* @access private
*/
this.currentPrograms = [];
/**
* OR検索できないサービスなら真。
* @type {boolean}
*/
this.disabledOr = details.disabledOr;
/**
* 検索ワードにヒットした番組を繰り返し取得します。
* @param {HTTPRequestInit} [nextSearchInit] - 次のページの取得に必要な情報。
* @returns {Promise}
* @fires Service#ProgramEvent:progress
* @fires Service#LoadedEvent:load
* @access private
*/
this.getHitPrograms = function (nextSearchInit) {
if (this.enabled) {
/**
* 現在の検索ワード。接続前と取得完了時の検索ワードの変化を防ぎます。
* @type {SearchCriteria[]}
*/
var words = UserSettings.words;
var searchInit = nextSearchInit
|| details.getDetails(details.disabledOr ? words[this.wordIndex] : words);
searchInit.timeout = Service.TIMEOUT;
this.request = new HTTPRequest(searchInit);
this.request.send().then(response => {
var programs = details.parseResponse(response, searchInit, words);
if (programs instanceof Error) {
return Promise.reject(error);
}
// 番組情報の取得に成功していれば
for (var program of Array.from(programs.programs)) {
var entry = details.convertIntoEntry(program);
entry.service = this;
var hit = false;
if (details.disabledOr) {
// OR検索ができないサービスなら
entry.searchCriteria = words[this.wordIndex];
}
if (details.disabledSearch) {
// 全件取得が可能なサービスなら
hit = WordProcessor.orSearch(entry.getSearchTarget(), words);
if (hit) {
entry.searchCriteria = hit;
}
} else if (details.disabledMinus) {
// マイナス検索ができないサービスなら
hit = WordProcessor.minusSearch(entry.getSearchTarget(), entry.searchCriteria.minus);
} else {
hit = true;
}
// 言語の絞り込み
if (hit && document.getElementsByName('languageFilter')[0].checked && details.disabledLanguageFilter
&& window.navigator.language.split('-')[0] !== entry.language.split('-')[0]) {
hit = false;
}
if (hit) {
entry.service = this;
this.currentPrograms.push(entry);
this.dispatchEvent(new CustomEvent('progress', { detail: entry }));
}
}
if (programs.next) {
// 次のページが存在すれば
this.timer = window.setTimeout(this.getHitPrograms.bind(this), details.pagingDelay || this.DEFAULT_PAGING_DELAY, programs.next);
} else {
// 取得完了
this.dispatchEvent(new CustomEvent('load', { detail: {
service: this,
programs: this.currentPrograms,
searchCriteria: details.disabledOr ? words[this.wordIndex] : null,
}}));
this.currentPrograms = [];
if (details.disabledOr) {
// OR検索ができないサービスなら
this.wordIndex++;
if (!UserSettings.words[this.wordIndex]) {
// 次の検索語句が存在しなければ
this.wordIndex = 0;
}
}
this.timer = window.setTimeout(this.getHitPrograms.bind(this), details.delay || this.DEFAULT_DELAY);
}
}).catch(error => {
// 番組情報の取得に失敗していれば
if (!(error instanceof AbortException)) {
// 意図的な停止による例外でなければ
console.log(error);
UserSettings.showLatestError(this, error);
// 一定時間後に最初から検索をやり直す
this.timer = window.setTimeout(this.getHitPrograms.bind(this), Service.RETRY_DELAY);
}
});
}
};
// EventTargetの疑似継承
// <http://stackoverflow.com/questions/22186467/how-to-use-javascript-eventtarget#answer-24216547>
var eventTarget = new DocumentFragment();
for (var key in this) {
eventTarget[key] = typeof this[key] === 'function' ? this[key].bind(this) : this[key];
}
for (var methodName of ['addEventListener', 'removeEventListener', 'dispatchEvent']) {
this[methodName] = eventTarget[methodName].bind(eventTarget);
}
};
/**
* タイムアウトミリ秒数。
* @constant {number}
*/
Service.TIMEOUT = 10 * DateUtils.SECONDS_TO_MILISECONDS;
/**
* メンテナンス中など、サーバー側のエラーが発生した場合に再度取得する間隔。ミリ秒。
* @constant {number}
*/
Service.RETRY_DELAY = 15 * DateUtils.MINUTES_TO_MILISECONDS;
/**
* 検索を開始します。
*/
Service.prototype.start = function () {
if (!this.enabled) {
// 検索が無効であれば
this.enabled = true;
this.getHitPrograms();
}
};
/**
* 検索を停止します。
*/
Service.prototype.stop = function () {
this.enabled = false;
if (this.request) {
this.request.abort();
}
window.clearTimeout(this.timer);
};
/**
* 検索語句を読み込み直して最初から検索しなおします。
*/
Service.prototype.reset = function () {
if (this.disabledOr) {
this.wordIndex = 0;
TableProcessor.removeProgramsWithService(this);
}
};
/**
* 1つのライブ配信番組。
* @param {string} link - 配信のURL。
* @param {string} title - 配信のタイトル。
* @param {Object} otherDetails
* @param {string} [otherDetails.icon] - コミュニティやチャンネルなどのアイコンのURL。取得できなければユーザーのアイコンのURL。それも取得できなければ配信のアイコンのURL。
* @param {boolean} [otherDetails.private] - プライベート配信であれば真。
* @param {Date} [otherDetails.published] - 配信開始日時。
* @param {Person} [otherDetails.author] - 配信者。
* @param {string[]} [otherDetails.categories] - 配信のタグ。カテゴリを含みます。
* @param {string} [otherDetails.summary] - 配信の説明文。
* @param {number} [otherDetails.visitors] - 累計視聴者数。取得できなければ最高同時視聴者数。それも取得できなければ現在の視聴者数。
* @param {number} [otherDetails.comments] - コメントの数。
* @param {Person} [otherDetails.community] - コミュニティやチャンネルなど。
* @param {string} [otherDetails.language] - 言語。
* @param {SearchCriteria} [details.searchCriteria] - 検索条件。
*/
window.Program = function (link, title, otherDetails, searchCriteria) {
this.link = link;
this.title = StringProcessor.normalize(title);
for (var key in otherDetails) {
var value = otherDetails[key];
if (value !== undefined && value !== null) {
switch (key) {
case 'summary':
value = StringProcessor.normalize(value);
break;
case 'categories':
value = value.map(StringProcessor.normalize);
break;
case 'community':
value.name = StringProcessor.normalize(value.name);
break;
}
this[key] = value;
}
}
this.searchCriteria = searchCriteria;
/**
* @type {Service}
*/
this.service;
};
/**
* 配信者、またはコミュニティ。
* @typedef {Object} Person
* @property {string} name - 名前。
* @property {string} [url] - URL。
*/
/**
* 検索対象を取得します。
* @return {string} 文字種を統一した文字列。
*/
Program.prototype.getSearchTarget = function () {
var target = [this.title];
if (this.categories) {
target.concat(this.categories);
}
if (this.summary) {
target.push(this.summary);
}
if (this.community) {
target.push(this.community.name);
}
return StringProcessor.unifyCases(target.join(' '));
};
}
/**
* 挿入された節の親節が、目印となる節の親節か否かを返すコールバック関数。
* @callback isTargetParent
* @param {(Document|Element)} parent
* @returns {boolean}
*/
/**
* 挿入された節が、目印となる節か否かを返すコールバック関数。
* @callback isTarget
* @param {(DocumentType|Element)} target
* @returns {boolean}
*/
/**
* 目印となる節が文書に存在するか否かを返すコールバック関数。
* @callback existsTarget
* @returns {boolean}
*/
/**
* 目印となる節が挿入された直後に関数を実行する。
* @param {Function} main - 実行する関数。
* @param {isTargetParent} isTargetParent
* @param {isTarget} isTarget
* @param {existsTarget} existsTarget
* @param {Object} [callbacksForFirefox]
* @param {isTargetParent} [callbacksForFirefox.isTargetParent] - Firefoxにおける{@link isTargetParent}。
* @param {isTarget} [callbacksForFirefox.isTarget] - Firefoxにおける{@link isTarget}。
* @param {number} [timeoutSinceStopParsingDocument=0] - DOM構築完了後に監視を続けるミリ秒数。
* @version 2014-11-25
* @global
*/
function startScript(main, isTargetParent, isTarget, existsTarget) {
/**
* {@link checkExistingTarget}で{@link startMain}を実行する間隔(ミリ秒)。
* @constant {number}
*/
var INTERVAL = 10;
/**
* {@link checkExistingTarget}で{@link startMain}を実行する回数。
* @constant {number}
*/
var LIMIT = 500;
/**
* 実行済みなら真。
* @type {boolean}
*/
var alreadyCalled = false;
// 指定した節が既に存在していれば、即実行
startMain();
if (alreadyCalled) {
return;
}
// FirefoxのMutationObserverは、HTMLのDOM構築に関して要素をまとめて挿入したと見なすため、isTargetParent、isTargetを変更
var callbacksForFirefox = arguments[4];
if (callbacksForFirefox && typeof MozSettingsEvent !== 'undefined') {
isTargetParent = callbacksForFirefox.isTargetParent || isTargetParent;
isTarget = callbacksForFirefox.isTarget || isTarget;
}
var observer = new MutationObserver(mutationCallback);
observer.observe(document, {
childList: true,
subtree: true,
});
var timeoutSinceStopParsingDocument = arguments[5] || 0;
if (document.readyState === 'complete') {
// DOMの構築が完了していれば
onDOMContentLoaded();
} else {
document.addEventListener('DOMContentLoaded', onDOMContentLoaded);
}
/**
* {@link startMain}を実行し、スクリプトが開始されていなければさらに{@link timeoutSinceStopParsingDocument}ミリ秒待機し、
* スクリプトが開始されていなければ{@link stopObserving}を実行する。
*/
function onDOMContentLoaded() {
startMain();
if (timeoutSinceStopParsingDocument === 0) {
if (!alreadyCalled) {
stopObserving();
}
} else {
window.setTimeout(function () {
if (!alreadyCalled) {
stopObserving();
}
}, timeoutSinceStopParsingDocument);
}
}
/**
* 目印となる節が挿入されたら、監視を停止し、{@link checkExistingTarget}を実行する。
* @param {MutationRecord[]} mutations - A list of MutationRecord objects.
* @param {MutationObserver} observer - The constructed MutationObserver object.
*/
function mutationCallback(mutations, observer) {
for (var mutation of mutations) {
var target = mutation.target;
if (target.nodeType === Node.ELEMENT_NODE && isTargetParent(target)) {
// 子が追加された節が要素節で、かつその節についてisTargetParentが真を返せば
for (var addedNode of mutation.addedNodes) {
if (addedNode.nodeType === Node.ELEMENT_NODE && isTarget(addedNode)) {
// 追加された子が要素節で、かつその節についてisTargetが真を返せば
observer.disconnect();
checkExistingTarget(0);
return;
}
}
}
}
}
/**
* {@link startMain}を実行し、スクリプトが開始されていなければ再度実行。
* @param {number} count - {@link startMain}を実行した回数。
*/
function checkExistingTarget(count) {
startMain();
if (!alreadyCalled && count < LIMIT) {
window.setTimeout(checkExistingTarget, INTERVAL, count + 1);
}
}
/**
* 指定した節が存在するか確認し、存在すれば{@link stopObserving}を実行しスクリプトを開始。
*/
function startMain() {
if (!alreadyCalled && existsTarget()) {
stopObserving();
main();
}
}
/**
* 監視を停止する。
*/
function stopObserving() {
alreadyCalled = true;
if (observer) {
observer.disconnect();
}
document.removeEventListener('DOMContentLoaded', onDOMContentLoaded);
}
}
/**
* 国際化・地域化関数を定義します。
*/
function defineGettext() {
/**
* 以下のような形式の翻訳リソース。すべての言語について、msgidは欠けていないものとする。
* {@link Gettext.DEFAULT_LOCALE}のリソースを必ず含む。{@link Gettext.ORIGINAL_LOCALE}のリソースは無視される。
* {
* 'IETF言語タグ': {
* '翻訳前 (msgid)': '翻訳後 (msgstr)',
* ……
* },
* ……
* }
* @typedef {Object} LocalizedTexts
*/
/**
* i18n。
* @version 2014-07-10
*/
window.Gettext = {
/**
* 翻訳対象文字列 (msgid) の言語。IETF言語タグの「language」サブタグ。
* @constant {string}
*/
ORIGINAL_LOCALE: 'ja',
/**
* クライアントの言語の翻訳リソースが存在しないとき、どの言語に翻訳するか。IETF言語タグの「language」サブタグ。
* @constant {string}
*/
DEFAULT_LOCALE: 'en',
/**
* 翻訳リソースを追加する。
* @param {LocalizedTexts} localizedTexts
*/
setLocalizedTexts: function (localizedTexts) {
this.multilingualLocalizedTexts = localizedTexts;
},
/**
* クライアントの言語を設定する。
* @param {string} clientLang - IETF言語タグ(「language」と「language-REGION」にのみ対応)。
*/
setLocale: function (clientLang) {
var splitedClientLang = clientLang.split('-', 2);
this.language = splitedClientLang[0].toLowerCase();
this.langtag = this.language + (splitedClientLang[1] ? '-' + splitedClientLang[1].toUpperCase() : '');
if (this.language === 'ja') {
// ja-JPをjaと同一視
this.langtag = this.language;
}
},
/**
* テキストをクライアントの言語に変換する。
* @param {string} message - 翻訳前。
* @returns {string} 翻訳後。
*/
gettext: function (message) {
// クライアントの言語が翻訳元の言語なら、そのまま返す
return this.langtag === this.ORIGINAL_LOCALE && message
// クライアントの言語の翻訳リソースが存在すれば、それを返す
|| this.langtag in this.multilingualLocalizedTexts && this.multilingualLocalizedTexts[this.langtag][message]
// 地域下位タグを取り除いた言語タグの翻訳リソースが存在すれば、それを返す
|| this.language in this.multilingualLocalizedTexts && this.multilingualLocalizedTexts[this.language][message]
// 既定言語の翻訳リソースが存在すれば、それを返す
|| this.DEFAULT_LOCALE in this.multilingualLocalizedTexts && this.multilingualLocalizedTexts[this.DEFAULT_LOCALE][message]
// そのまま返す
|| message;
},
/**
* クライアントの言語。{@link Gettext.setLocale}から変更される。
* @type {string}
* @access private
*/
langtag: 'ja',
/**
* クライアントの言語のlanguage部分。{@link Gettext.setLocale}から変更される。
* @type {string}
* @access private
*/
language: 'ja',
/**
* 翻訳リソース。{@link Gettext.setLocalizedTexts}から変更される。
* @type {LocalizedTexts}
* @access private
*/
multilingualLocalizedTexts: {},
};
window._ = Gettext.gettext.bind(Gettext);
}
/**
* ライブラリの定義、ECMAScriptとWHATWG仕様のPolyfill、prototype汚染回避など。
*/
function polyfill() {
/**
* DOM関連のメソッド等。
* @version 1.0.0
*/
window.DOMUtils = {
/**
* Atom名前空間。
* @constant {string}
*/
ATOM_NAMESPACE: 'http://www.w3.org/2005/Atom',
/**
* XMLの特殊文字を文字参照に置換します。
* @param {string} str - プレーンな文字列。
* @returns {string} HTMLとして扱われる文字列。
*/
convertSpecialCharactersToCharacterReferences: function (str) {
return String(str).replace(/[&<>"']/g, function (specialCharcter) {
return '&#x' + specialCharcter.charCodeAt(0).toString(16) + ';'
});
},
/**
* テンプレート文字列のタグとして用いることで、式内にあるXMLの特殊文字を文字参照に置換します。
* @param {string[]} htmlTexts
* @param {...string} plainText
* @returns {string} HTMLとして扱われる文字列。
*/
escapeTemplateStrings() {
return String.raw.apply(null, Array.prototype.map.call(arguments, function (plainText, i) {
return i === 0 ? plainText : this.convertSpecialCharactersToCharacterReferences(plainText);
}, this));
},
};
/**
* {@link DOMUtils.escapeTemplateStrings}、または {@link DOMUtils.convertSpecialCharactersToCharacterReferences} の短縮表記。
*/
window.h = function () {
return Array.isArray(arguments[0])
? DOMUtils.escapeTemplateStrings.apply(DOMUtils, arguments)
: DOMUtils.convertSpecialCharactersToCharacterReferences(arguments[0]);
};
/**
* HTTPリクエスト。
* @param {HTTPRequestInit} init
* @constructor
* @version 1.0.0
*/
window.HTTPRequest = function (init) {
/** @access private */
this.details = init;
/**
* @param {Object} client
* @param {Function} resolve
* @param {Function} reject
* @param {string} [responseType]
* @access private
*/
this.onload = function (client, resolve, reject, responseType) {
if (client.status === 200) {
var response;
var errorMessage;
if (client instanceof XMLHttpRequest) {
if (client.response) {
resolve(client.response);
} else {
reject(new SyntaxError('Parsing HTTP response body was failed.'));
}
} else if (client.responseText === '') {
reject(new SyntaxError('HTTP response body was empty.'));
} else {
switch (this.details.responseType) {
case 'json':
resolve(JSON.parse(client.responseText));
break;
case 'document':
var result = /^content-type\s*:\s*(text\/html|text\/xml|application\/xml|\S+\+xml)(?:;|\s|$)/im.exec(client.responseHeaders);
if (result) {
result = new DOMParser().parseFromString(
client.responseText,
result[1] === 'text/html' ? 'text/html' : 'application/xml'
);
var parsererror = result.getElementsByTagName('parsererror')[0];
if (parsererror) {
reject(new SyntaxError(parsererror.textContent));
} else {
if (result.head) {
result.head.insertAdjacentHTML('beforeend', h`<base href="${client.finalUrl}" />`);
}
resolve(result);
}
}
break;
case 'text':
resolve(client.responseText);
break;
}
}
} else {
reject(new ErrorStatusException(client.status));
}
};
/**
* @param {Function} reject
* @access private
*/
this.ontimeout = function (reject) {
reject(new TimeoutException());
};
/**
* @param {Function} reject
* @access private
*/
this.onabort = function (reject) {
reject(new AbortException());
};
};
/**
* HTTPリクエストに必要な情報。
* @typedef {Object} HTTPRequestInit
* @see [GM_xmlhttpRequest - GreaseSpot Wiki]{@link http://wiki.greasespot.net/GM_xmlhttpRequest#Arguments}
* @see [Fetch Standard (日本語訳)]{@link http://www.hcn.zaq.ne.jp/___/WEB/Fetch-ja.html#requestinit}
* @property {string} method - 「GET」「POST」のいずれか。
* @property {string} url
* @property {string} responseType - 「document」「json」「text」のいずれか。
* @property {Object.<string>} headers
* @property {number} [timeout] - ミリ秒。
* @property {(string|Object)} [data]
* @property {string} mode - {@link XMLHttpRequest} を使用するなら「cors」、{@link GM_xmlhttpRequest} を使用する必要があれば「no-cors」を指定する。
*/
/**
* リクエストを送信します。
* @returns {Promise.<string|Object>}
*/
HTTPRequest.prototype.send = function () {
return new Promise((resolve, reject) => {
if (this.details.mode === 'cors') {
/**
* abort() メソッドを持つオブジェクト。
* @type {Object}
*/
this.client = new XMLHttpRequest();
this.client.open(this.details.method, this.details.url);
this.client.responseType = this.details.responseType;
this.client.addEventListener('load', event => {
this.onload(event.target, resolve, reject);
});
this.client.addEventListener('timeout', () => {
this.ontimeout(reject);
});
this.client.addEventListener('abort', () => {
this.onabort(reject);
});
if (this.details.headers) {
for (var name in this.details.headers) {
this.client.setRequestHeader(name, this.details.headers[name]);
}
}
if (this.details.timeout) {
this.client.timeout = this.details.timeout;
}
if (this.details.data && typeof this.details.data === 'object') {
this.client.setRequestHeader('content-type', 'application/json');
this.client.send(JSON.stringify(this.details.data));
} else {
this.client.send(this.details.data);
}
} else {
var details = {};
for (var key in this.details) {
details[key] = this.details[key];
}
delete details.responseType;
details.onload = responseObject => {
this.onload(responseObject, resolve, reject, details.responseType);
};
details.ontimeout = () => {
this.ontimeout(reject);
};
details.onabort = () => {
this.onabort(reject);
};
this.client = GM_xmlhttpRequest(details);
}
});
};
/**
* リクエストを取り消します。
*/
HTTPRequest.prototype.abort = function () {
if (this.client) {
this.client.abort();
}
};
/**
* @param {string} message
* @constructor
* @see [Custom Error Types | Error - JavaScript | MDN]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Custom_Error_Types}
*/
window.ConnectionException = function (message) {
this.name = 'ConnectionException';
this.message = message || 'Connection exception occured.';
this.stack = (new Error()).stack;
}
ConnectionException.prototype = Object.create(Error.prototype);
ConnectionException.prototype.constructor = ConnectionException;
/**
* @param {string} message
* @constructor
*/
window.TimeoutException = function (message) {
this.name = 'TimeoutException';
this.message = message || 'Connection timed out.';
this.stack = (new ConnectionException()).stack;
}
TimeoutException.prototype = Object.create(ConnectionException.prototype);
TimeoutException.prototype.constructor = TimeoutException;
/**
* @param {number} code - HTTPステータスコード。
* @param {string} [message]
* @constructor
*/
window.ErrorStatusException = function (code, message) {
this.name = 'ErrorStatusException';
this.message = message || ('HTTP status-code was %s.').replace('%s', code);
this.stack = (new ConnectionException()).stack;
/**
* HTTPステータスコード。
* @type {number}
*/
this.code = code;
}
ErrorStatusException.prototype = Object.create(ConnectionException.prototype);
ErrorStatusException.prototype.constructor = ErrorStatusException;
/**
* @param {string} message
* @constructor
*/
window.AbortException = function (message) {
this.name = 'AbortException';
this.message = message || 'Request was aborted.';
this.stack = (new ConnectionException()).stack;
}
AbortException.prototype = Object.create(ConnectionException.prototype);
AbortException.prototype.constructor = AbortException;
/**
* 時間に関するユーティリティクラス。
* @version 1.0.0
*/
window.DateUtils = {
/**
* 日をミリ秒に変換するときの乗数。
* @constant {number}
*/
DAYS_TO_MILISECONDS: 24 * 60 * 60 * 1000,
/**
* 時間をミリ秒に変換するときの乗数。
* @constant {number}
*/
HOURS_TO_MILISECONDS: 60 * 60 * 1000,
/**
* 分をミリ秒に変換するときの乗数。
* @constant {number}
*/
MINUTES_TO_MILISECONDS: 60 * 1000,
/**
* 秒をミリ秒に変換するときの乗数。
* @constant {number}
*/
SECONDS_TO_MILISECONDS: 1000,
/**
* 現在時刻から指定した時刻を引いた差を返します。
* @param {Date} value
* @returns {Object.<string>} dateTimeプロパティに ISO 8601 形式の文字列 (負になる場合は「PT0S」)、textプロパティに「○時間○分」のような形式の文字列。
*/
getDuration: function (value) {
var milliseconds = Date.now() - value.getTime();
var minutes = Math.round(milliseconds / this.MINUTES_TO_MILISECONDS);
var sign = minutes >= 0 ? 1 : -1;
minutes = Math.abs(minutes);
var hours = Math.floor(minutes / 60);
minutes = minutes % 60;
return {
dateTime: `PT${sign === -1 ? 0 : milliseconds / this.SECONDS_TO_MILISECONDS}S`,
text: hours ? _('%d 時間 %u 分').replace('%d', sign * hours).replace('%u', minutes) : _('%d 分').replace('%d', sign * minutes),
};
},
/**
* ISO 8601 形式の文字列からミリ秒数を取得します。
* @param {string} dateTime
* @returns {?number}
*/
parseDurationString: function (dateTime) {
var duration = null;
var result = /^P([0-9]+D)?(?:T([0-9]+H)?([0-9]+M)?([0-9]+(?:\.[0-9]{0,3})?S)?)?$/.exec(dateTime);
if (result) {
duration = 0;
if (result[1]) {
duration += Number.parseInt(result[1]) * this.DAYS_TO_MILISECONDS;
}
if (result[2]) {
duration += Number.parseInt(result[2]) * this.HOURS_TO_MILISECONDS;
}
if (result[3]) {
duration += Number.parseInt(result[3]) * this.MINUTES_TO_MILISECONDS;
}
if (result[4]) {
duration += Number.parseFloat(result[4]) * this.SECONDS_TO_MILISECONDS;
}
}
return duration;
},
/**
* 日本標準時の時刻文字列 (hh:mm) をDateインスタンスに変換します。
* @param {string} time - 過去を表す時刻。
* @returns {Date}
*/
parseJSTString: function (time) {
/**
* 日本標準時 (+09:00) の時間帯 (UTCとの差)。ミリ秒。
* @constant {number}
*/
var TIMEZONE_JST = 9 * this.HOURS_TO_MILISECONDS;
var hoursAndMinutes = time.split(':');
var date = new Date();
date.setUTCHours(Number.parseInt(hoursAndMinutes[0]), Number.parseInt(hoursAndMinutes[1]), 0, 0);
date.setTime(date.getTime() - TIMEZONE_JST);
if (date.getTime() > Date.now()) {
date.setTime(date.getTime() - this.DAYS_TO_MILISECONDS);
}
return date;
},
};
// For Firefox
if (typeof cloneInto !== 'undefined' && typeof Proxy !== 'undefined' /* exclude Tampermonkey*/) {
CustomEvent = new Proxy(CustomEvent, { construct: function (target, args) {
if (args.length >= 2
&& typeof args[1] === 'object' && typeof args[1].detail === 'object' && args[1].detail !== null) {
args[1].detail = cloneInto(args[1].detail, window, {
cloneFunctions: true,
wrapReflectors: true,
});
}
return eval('new target(...args)');
} });
}
/**
* Webページ側のコンテキストでなければprototype拡張ができない問題に対処します。
* @param {*} value - prototype拡張を行ったインターフェースのインスタンス。
* @returns {*} FirefoxであればProxyインスタンス。
* @version 1.0.0
*/
window.p = function (value) {
var element;
if (typeof MozSettingsEvent !== 'undefined') {
switch (typeof value) {
case 'string':
value = new String(value);
break;
case 'number':
value = new Number(value);
break;
case 'boolean':
value = new Boolean(value);
}
value = new Proxy(value, {
get: function (target, name) {
var value;
if (name === 'dataset' && target instanceof HTMLElement) {
// DOMStringMap deleter の実装
element = target;
value = new Proxy(target.dataset, {
deleteProperty: function (dataset, name) {
element.removeAttribute(
'data-' + name.replace(/[A-Z]/g, upper => '-' + upper.toLowerCase())
);
return true;
},
});
} else if (name in target) {
value = target[name];
} else {
for (var proto = target.__proto__; proto; proto = proto.__proto__) {
if (name in proto) {
var descriptor = Object.getOwnPropertyDescriptor(proto, name);
value = descriptor.value || descriptor.get.call(target);
break;
}
}
}
return typeof value === 'function' ? value.bind(target) : value;
},
set: function (target, name, value) {
if (name in target) {
target[name] = value;
} else {
var set = false;
for (var proto = target.__proto__; proto; proto = proto.__proto__) {
if (name in proto) {
Object.getOwnPropertyDescriptor(proto, name).set.call(target);
set = true;
break;
}
}
if (!set) {
target[name] = value;
}
}
return true;
},
});
}
return value;
};
// Polyfill for Firefox, Opera, and Google Chrome
if (!('createFor' in URL)) {
/** @see [Bug 1062917 - Implement URL.createFor]{@link https://bugzilla.mozilla.org/show_bug.cgi?id=1062917} */
Object.defineProperty(URL, 'createFor', {
writable: true,
enumerable: false,
configurable: true,
value: function (blob) {
/**
* 分をミリ秒に変換するときの乗数。
* @constant {number}
*/
var MINUTES_TO_MILISECONDS = 60 * 1000;
/**
* Blob URL を自動破棄するまでのミリ秒数。
* @constant {number}
*/
var MAX_LIFETIME = 10 * MINUTES_TO_MILISECONDS;
var url = this.createObjectURL(blob);
window.setTimeout(this.revokeObjectURL.bind(this), MAX_LIFETIME, url);
return url;
},
});
}
// Polyfill for Firefox 38 ESR, Opera, and Google Chrome
try {
new DragEvent('drag');
} catch (e) {
/**
* @constructor
* @param {string} type
* @param {DragEventInit} [eventInitDict]
* @see [Bug 1135627 - DragEvent constructor throws "TypeError: Illegal constructor."]{@link https://bugzilla.mozilla.org/show_bug.cgi?id=1135627}
* @see [Issue 498504 - chromium - Implement DragEvent and move MouseEvent.dataTransfer to DragEvent]{@link https://code.google.com/p/chromium/issues/detail?id=498504}
* @see [The DragEvent interface]{@link https://html.spec.whatwg.org/multipage/interaction.html#the-dragevent-interface}
* @name DragEvent
*/
Object.defineProperty(window, 'DragEvent', {
writable: true,
enumerable: false,
configurable: true,
value: MouseEvent,
});
}
// Polyfill for Firefox 38 ESR
if (!''.includes) {
/** @see [Bug 1102219 – Rename String.prototype.contains to String.prototype.includes]{https://bugzilla.mozilla.org/show_bug.cgi?id=1102219} */
Object.defineProperty(String.prototype, 'includes', {
writable: true,
enumerable: false,
configurable: true,
value: String.prototype.contains,
});
}
// Polyfill for Opera and Google Chrome
// prototype汚染が行われる Prototype JavaScript Framework (prototype.js) 1.6.0.3 のバグを修正
if (window.chrome) {
Object.defineProperty(Array, 'from', { writable: false });
Object.defineProperty(Object, 'extend', {
writable: false,
value: function (destination, source) {
for (var property in source) {
if (property in destination || property === 'toJSON') {
continue;
}
destination[property] = source[property];
}
return destination;
},
});
}
if (!(Symbol.iterator in NodeList.prototype)) {
Object.defineProperties(NodeList.prototype, /** @lends NodeList# */ {
/**
* @see [Issue 401699 - chromium - Add iterator support to NodeList and friends]{@link https://code.google.com/p/chromium/issues/detail?id=401699}
* @version 1.1.0
* @returns {Iterator.<Array.<number, Node>>}
* @name NodeList#@@iterator
*/
[Symbol.iterator]: {
writable: true,
enumerable: false,
configurable: true,
value: function* () {
for (var i = 0, l = this.length; i < l; i++) {
yield this[i];
}
}
},
/**
* @returns {Iterator.<Array.<number, Node>>}
* @function
*/
entries: {
writable: true,
enumerable: false,
configurable: true,
value: function* () {
for (var i = 0, l = this.length; i < l; i++) {
yield [i, this[i]];
}
}
},
/**
* @returns {Iterator.<number>}
* @function
*/
keys: {
writable: true,
enumerable: false,
configurable: true,
value: function* () {
for (var i = 0, l = this.length; i < l; i++) {
yield i;
}
}
},
/**
* @returns {Iterator.<Node>}
* @function
*/
values: {
writable: true,
enumerable: false,
configurable: true,
value: function* () {
for (var i = 0, l = this.length; i < l; i++) {
yield this[i];
}
}
},
});
}
if (!CSS.escape) {
var global = window;
/*! https://mths.be/cssescape v1.1.0 by @mathias | MIT license */
;(function(root) {
if (!root.CSS) {
root.CSS = {};
}
var CSS = root.CSS;
var InvalidCharacterError = function(message) {
this.message = message;
};
InvalidCharacterError.prototype = new Error;
InvalidCharacterError.prototype.name = 'InvalidCharacterError';
if (!CSS.escape) {
// https://drafts.csswg.org/cssom/#serialize-an-identifier
CSS.escape = function(value) {
var string = String(value);
var length = string.length;
var index = -1;
var codeUnit;
var result = '';
var firstCodeUnit = string.charCodeAt(0);
while (++index < length) {
codeUnit = string.charCodeAt(index);
// Note: there’s no need to special-case astral symbols, surrogate
// pairs, or lone surrogates.
// If the character is NULL (U+0000), then throw an
// `InvalidCharacterError` exception and terminate these steps.
if (codeUnit == 0x0000) {
throw new InvalidCharacterError(
'Invalid character: the input contains U+0000.'
);
}
if (
// If the character is in the range [\1-\1F] (U+0001 to U+001F) or is
// U+007F, […]
(codeUnit >= 0x0001 && codeUnit <= 0x001F) || codeUnit == 0x007F ||
// If the character is the first character and is in the range [0-9]
// (U+0030 to U+0039), […]
(index == 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
// If the character is the second character and is in the range [0-9]
// (U+0030 to U+0039) and the first character is a `-` (U+002D), […]
(
index == 1 &&
codeUnit >= 0x0030 && codeUnit <= 0x0039 &&
firstCodeUnit == 0x002D
)
) {
// https://drafts.csswg.org/cssom/#escape-a-character-as-code-point
result += '\\' + codeUnit.toString(16) + ' ';
continue;
}
if (
// If the character is the first character and is a `-` (U+002D), and
// there is no second character, […]
index == 0 &&
length == 1 &&
codeUnit == 0x002D
) {
result += '\\' + string.charAt(index);
continue;
}
// If the character is not handled by one of the above rules and is
// greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or
// is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to
// U+005A), or [a-z] (U+0061 to U+007A), […]
if (
codeUnit >= 0x0080 ||
codeUnit == 0x002D ||
codeUnit == 0x005F ||
codeUnit >= 0x0030 && codeUnit <= 0x0039 ||
codeUnit >= 0x0041 && codeUnit <= 0x005A ||
codeUnit >= 0x0061 && codeUnit <= 0x007A
) {
// the character itself
result += string.charAt(index);
continue;
}
// Otherwise, the escaped character.
// https://drafts.csswg.org/cssom/#escape-a-character
result += '\\' + string.charAt(index);
}
return result;
};
}
}(typeof global != 'undefined' ? global : this));
}
/*
* jsen
* https://github.com/bugventure/jsen
*
* Copyright (c) 2015 Veli Pehlivanov <bugventure@gmail.com>
* Licensed under the MIT license <http://opensource.org/licenses/mit>
*/
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.jsen = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
module.exports = require('./lib/jsen.js');
},{"./lib/jsen.js":5}],2:[function(require,module,exports){
'use strict';
function type(obj) {
var str = Object.prototype.toString.call(obj);
return str.substr(8, str.length - 9).toLowerCase();
}
function deepEqual(a, b) {
var keysA = Object.keys(a).sort(),
keysB = Object.keys(b).sort(),
i, key;
if (!equal(keysA, keysB)) {
return false;
}
for (i = 0; i < keysA.length; i++) {
key = keysA[i];
if (!equal(a[key], b[key])) {
return false;
}
}
return true;
}
function equal(a, b) { // jshint ignore: line
var typeA = typeof a,
typeB = typeof b,
i;
// get detailed object type
if (typeA === 'object') {
typeA = type(a);
}
// get detailed object type
if (typeB === 'object') {
typeB = type(b);
}
if (typeA !== typeB) {
return false;
}
if (typeA === 'object') {
return deepEqual(a, b);
}
if (typeA === 'regexp') {
return a.toString() === b.toString();
}
if (typeA === 'array') {
if (a.length !== b.length) {
return false;
}
for (i = 0; i < a.length; i++) {
if (!equal(a[i], b[i])) {
return false;
}
}
return true;
}
return a === b;
}
module.exports = equal;
},{}],3:[function(require,module,exports){
'use strict';
var formats = {};
// reference: http://dansnetwork.com/javascript-iso8601rfc3339-date-parser/
formats['date-time'] = /(\d\d\d\d)(-)?(\d\d)(-)?(\d\d)(T)?(\d\d)(:)?(\d\d)(:)?(\d\d)(\.\d+)?(Z|([+-])(\d\d)(:)?(\d\d))/;
// reference: https://github.com/mafintosh/is-my-json-valid/blob/master/formats.js#L7
formats.uri = /^([a-zA-Z][a-zA-Z0-9+-.]*:){0,1}\/\/[^\s]*$/;
// reference: http://stackoverflow.com/questions/201323/using-a-regular-expression-to-validate-an-email-address#answer-8829363
// http://www.w3.org/TR/html5/forms.html#valid-e-mail-address (search for 'willful violation')
formats.email = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
// reference: https://www.safaribooksonline.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html
formats.ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
// reference: http://stackoverflow.com/questions/53497/regular-expression-that-matches-valid-ipv6-addresses
formats.ipv6 = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|[fF][eE]80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::([fF]{4}(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
// reference: http://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address#answer-3824105
formats.hostname = /^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]))*$/;
module.exports = formats;
},{}],4:[function(require,module,exports){
'use strict';
module.exports = function func() {
var name = arguments[0] || '',
args = [].join.call([].slice.call(arguments, 1), ', '),
lines = '',
vars = '',
ind = 1,
tab = ' ',
bs = '{[', // block start
be = '}]', // block end
space = function () {
return new Array(ind + 1).join(tab);
},
push = function (line) {
lines += space() + line + '\n';
},
builder = function (line) {
var first = line[0],
last = line[line.length - 1];
if (be.indexOf(first) > -1 && bs.indexOf(last) > -1) {
ind--;
push(line);
ind++;
}
else if (bs.indexOf(last) > -1) {
push(line);
ind++;
}
else if (be.indexOf(first) > -1) {
ind--;
push(line);
}
else {
push(line);
}
return builder;
};
builder.def = function (id, def) {
vars += space() + 'var ' + id + (def !== undefined ? ' = ' + def : '') + '\n';
return builder;
};
builder.toSource = function () {
return 'function ' + name + '(' + args + ') {\n' + vars + '\n' + lines + '\n}';
};
builder.compile = function (scope) {
var src = 'return (' + builder.toSource() + ')',
scp = scope || {},
keys = Object.keys(scp),
vals = keys.map(function (key) { return scp[key]; });
return Function.apply(null, keys.concat(src)).apply(null, vals);
};
return builder;
};
},{}],5:[function(require,module,exports){
'use strict';
var PATH_REPLACE_EXPR = /\[.+?\]/g,
PATH_PROP_REPLACE_EXPR = /\[?(.*?)?\]/,
REGEX_ESCAPE_EXPR = /[\/]/g,
VALID_IDENTIFIER_EXPR = /^[a-z_$][0-9a-z]*$/gi,
INVALID_SCHEMA = 'jsen: invalid schema object',
browser = typeof window === 'object' && !!window.navigator, // jshint ignore: line
func = require('./func.js'),
equal = require('./equal.js'),
unique = require('./unique.js'),
SchemaResolver = require('./resolver.js'),
formats = require('./formats.js'),
types = {},
keywords = {};
function inlineRegex(regex) {
var str = regex instanceof RegExp ? regex.toString() : new RegExp(regex).toString();
if (browser) {
return str;
}
str = str.substr(1, str.length - 2);
str = '/' + str.replace(REGEX_ESCAPE_EXPR, '\\$&') + '/';
return str;
}
function appendToPath(path, key) {
VALID_IDENTIFIER_EXPR.lastIndex = 0;
return VALID_IDENTIFIER_EXPR.test(key) ?
path + '.' + key :
path + '["' + key + '"]';
}
function type(obj) {
var str = Object.prototype.toString.call(obj);
return str.substr(8, str.length - 9).toLowerCase();
}
function isInteger(obj) {
return (obj | 0) === obj; // jshint ignore: line
}
types['null'] = function (path) {
return path + ' === null';
};
types.boolean = function (path) {
return 'typeof ' + path + ' === "boolean"';
};
types.string = function (path) {
return 'typeof ' + path + ' === "string"';
};
types.number = function (path) {
return 'typeof ' + path + ' === "number"';
};
types.integer = function (path) {
return 'typeof ' + path + ' === "number" && !(' + path + ' % 1)';
};
types.array = function (path) {
return path + ' !== undefined && Array.isArray(' + path + ')';
};
types.object = function (path) {
return path + ' !== undefined && typeof ' + path + ' === "object" && ' + path + ' !== null && !Array.isArray(' + path + ')';
};
types.date = function (path) {
return path + ' !== undefined && ' + path + ' instanceof Date';
};
keywords.type = function (context) {
if (!context.schema.type) {
return;
}
var specified = Array.isArray(context.schema.type) ? context.schema.type : [context.schema.type],
src = specified.map(function mapType(type) {
return types[type] ? types[type](context.path) || 'true' : 'true';
}).join(' || ');
if (src) {
context.code('if (!(' + src + ')) {');
context.error('type');
context.code('}');
}
};
keywords['enum'] = function (context) {
var arr = context.schema['enum'],
clauses = [],
value, enumType, i;
if (!Array.isArray(arr)) {
return;
}
for (i = 0; i < arr.length; i++) {
value = arr[i];
enumType = typeof value;
if (value === null || ['boolean', 'number', 'string'].indexOf(enumType) > -1) {
// simple equality check for simple data types
if (enumType === 'string') {
clauses.push(context.path + ' === "' + value + '"');
}
else {
clauses.push(context.path + ' === ' + value);
}
}
else {
// deep equality check for complex types or regexes
clauses.push('equal(' + context.path + ', ' + JSON.stringify(value) + ')');
}
}
context.code('if (!(' + clauses.join(' || ') + ')) {');
context.error('enum');
context.code('}');
};
keywords.minimum = function (context) {
if (typeof context.schema.minimum === 'number') {
context.code('if (' + context.path + ' < ' + context.schema.minimum + ') {');
context.error('minimum');
context.code('}');
}
};
keywords.exclusiveMinimum = function (context) {
if (context.schema.exclusiveMinimum === true && typeof context.schema.minimum === 'number') {
context.code('if (' + context.path + ' === ' + context.schema.minimum + ') {');
context.error('exclusiveMinimum');
context.code('}');
}
};
keywords.maximum = function (context) {
if (typeof context.schema.maximum === 'number') {
context.code('if (' + context.path + ' > ' + context.schema.maximum + ') {');
context.error('maximum');
context.code('}');
}
};
keywords.exclusiveMaximum = function (context) {
if (context.schema.exclusiveMaximum === true && typeof context.schema.maximum === 'number') {
context.code('if (' + context.path + ' === ' + context.schema.maximum + ') {');
context.error('exclusiveMaximum');
context.code('}');
}
};
keywords.multipleOf = function (context) {
if (typeof context.schema.multipleOf === 'number') {
var mul = context.schema.multipleOf,
decimals = mul.toString().length - mul.toFixed(0).length - 1,
pow = decimals > 0 ? Math.pow(10, decimals) : 1,
path = context.path;
if (decimals > 0) {
context.code('if (+(Math.round((' + path + ' * ' + pow + ') + "e+" + ' + decimals + ') + "e-" + ' + decimals + ') % ' + (mul * pow) + ' !== 0) {');
} else {
context.code('if (((' + path + ' * ' + pow + ') % ' + (mul * pow) + ') !== 0) {');
}
context.error('multipleOf');
context.code('}');
}
};
keywords.minLength = function (context) {
if (isInteger(context.schema.minLength)) {
context.code('if (' + context.path + '.length < ' + context.schema.minLength + ') {');
context.error('minLength');
context.code('}');
}
};
keywords.maxLength = function (context) {
if (isInteger(context.schema.maxLength)) {
context.code('if (' + context.path + '.length > ' + context.schema.maxLength + ') {');
context.error('maxLength');
context.code('}');
}
};
keywords.pattern = function (context) {
var regex = typeof context.schema.pattern === 'string' ?
new RegExp(context.schema.pattern) :
context.schema.pattern;
if (type(regex) === 'regexp') {
context.code('if (!(' + inlineRegex(regex) + ').test(' + context.path + ')) {');
context.error('pattern');
context.code('}');
}
};
keywords.format = function (context) {
if (typeof context.schema.format !== 'string' || !formats[context.schema.format]) {
return;
}
context.code('if (!(' + formats[context.schema.format] + ').test(' + context.path + ')) {');
context.error('format');
context.code('}');
};
keywords.minItems = function (context) {
if (isInteger(context.schema.minItems)) {
context.code('if (' + context.path + '.length < ' + context.schema.minItems + ') {');
context.error('minItems');
context.code('}');
}
};
keywords.maxItems = function (context) {
if (isInteger(context.schema.maxItems)) {
context.code('if (' + context.path + '.length > ' + context.schema.maxItems + ') {');
context.error('maxItems');
context.code('}');
}
};
keywords.additionalItems = function (context) {
if (context.schema.additionalItems === false && Array.isArray(context.schema.items)) {
context.code('if (' + context.path + '.length > ' + context.schema.items.length + ') {');
context.error('additionalItems');
context.code('}');
}
};
keywords.uniqueItems = function (context) {
if (context.schema.uniqueItems) {
context.code('if (unique(' + context.path + ').length !== ' + context.path + '.length) {');
context.error('uniqueItems');
context.code('}');
}
};
keywords.items = function (context) {
var index = context.declare(0),
i = 0;
if (type(context.schema.items) === 'object') {
context.code('for (' + index + '; ' + index + ' < ' + context.path + '.length; ' + index + '++) {');
context.validate(context.path + '[' + index + ']', context.schema.items, context.noFailFast);
context.code('}');
}
else if (Array.isArray(context.schema.items)) {
for (; i < context.schema.items.length; i++) {
context.code('if (' + context.path + '.length - 1 >= ' + i + ') {');
context.validate(context.path + '[' + i + ']', context.schema.items[i], context.noFailFast);
context.code('}');
}
if (type(context.schema.additionalItems) === 'object') {
context.code('for (' + index + ' = ' + i + '; ' + index + ' < ' + context.path + '.length; ' + index + '++) {');
context.validate(context.path + '[' + index + ']', context.schema.additionalItems, context.noFailFast);
context.code('}');
}
}
};
keywords.maxProperties = function (context) {
if (isInteger(context.schema.maxProperties)) {
context.code('if (Object.keys(' + context.path + ').length > ' + context.schema.maxProperties + ') {');
context.error('maxProperties');
context.code('}');
}
};
keywords.minProperties = function (context) {
if (isInteger(context.schema.minProperties)) {
context.code('if (Object.keys(' + context.path + ').length < ' + context.schema.minProperties + ') {');
context.error('minProperties');
context.code('}');
}
};
keywords.required = function (context) {
if (!Array.isArray(context.schema.required)) {
return;
}
for (var i = 0; i < context.schema.required.length; i++) {
context.code('if (' + appendToPath(context.path, context.schema.required[i]) + ' === undefined) {');
context.error('required', context.schema.required[i]);
context.code('}');
}
};
keywords.properties = function (context) {
if (context.validatedProperties) {
// prevent multiple generations of property validation
return;
}
var props = context.schema.properties,
propKeys = type(props) === 'object' ? Object.keys(props) : [],
patProps = context.schema.patternProperties,
patterns = type(patProps) === 'object' ? Object.keys(patProps) : [],
addProps = context.schema.additionalProperties,
addPropsCheck = addProps === false || type(addProps) === 'object',
prop, i, nestedPath;
// do not use this generator if we have patternProperties or additionalProperties
// instead, the generator below will be used for all three keywords
if (!propKeys.length || patterns.length || addPropsCheck) {
return;
}
for (i = 0; i < propKeys.length; i++) {
prop = propKeys[i];
nestedPath = appendToPath(context.path, prop);
context.code('if (' + nestedPath + ' !== undefined) {');
context.validate(nestedPath, props[prop], context.noFailFast);
context.code('}');
}
context.validatedProperties = true;
};
keywords.patternProperties = keywords.additionalProperties = function (context) {
if (context.validatedProperties) {
// prevent multiple generations of this function
return;
}
var props = context.schema.properties,
propKeys = type(props) === 'object' ? Object.keys(props) : [],
patProps = context.schema.patternProperties,
patterns = type(patProps) === 'object' ? Object.keys(patProps) : [],
addProps = context.schema.additionalProperties,
addPropsCheck = addProps === false || type(addProps) === 'object',
keys, key, n, found,
propKey, pattern, i;
if (!propKeys.length && !patterns.length && !addPropsCheck) {
return;
}
keys = context.declare('[]');
key = context.declare('""');
n = context.declare(0);
if (addPropsCheck) {
found = context.declare(false);
}
context.code(keys + ' = Object.keys(' + context.path + ')');
context.code('for (' + n + '; ' + n + ' < ' + keys + '.length; ' + n + '++) {')
(key + ' = ' + keys + '[' + n + ']')
('if (' + context.path + '[' + key + '] === undefined) {')
('continue')
('}');
if (addPropsCheck) {
context.code(found + ' = false');
}
// validate regular properties
for (i = 0; i < propKeys.length; i++) {
propKey = propKeys[i];
context.code((i ? 'else ' : '') + 'if (' + key + ' === "' + propKey + '") {');
if (addPropsCheck) {
context.code(found + ' = true');
}
context.validate(appendToPath(context.path, propKey), props[propKey], context.noFailFast);
context.code('}');
}
// validate pattern properties
for (i = 0; i < patterns.length; i++) {
pattern = patterns[i];
context.code('if ((' + inlineRegex(pattern) + ').test(' + key + ')) {');
if (addPropsCheck) {
context.code(found + ' = true');
}
context.validate(context.path + '[' + key + ']', patProps[pattern], context.noFailFast);
context.code('}');
}
// validate additional properties
if (addPropsCheck) {
context.code('if (!' + found + ') {');
if (addProps === false) {
// do not allow additional properties
context.error('additionalProperties');
}
else {
// validate additional properties
context.validate(context.path + '[' + key + ']', addProps, context.noFailFast);
}
context.code('}');
}
context.code('}');
context.validatedProperties = true;
};
keywords.dependencies = function (context) {
if (type(context.schema.dependencies) !== 'object') {
return;
}
var key, dep, i = 0;
for (key in context.schema.dependencies) {
dep = context.schema.dependencies[key];
context.code('if (' + appendToPath(context.path, key) + ' !== undefined) {');
if (type(dep) === 'object') {
//schema dependency
context.validate(context.path, dep, context.noFailFast);
}
else {
// property dependency
for (i; i < dep.length; i++) {
context.code('if (' + appendToPath(context.path, dep[i]) + ' === undefined) {');
context.error('dependencies', dep[i]);
context.code('}');
}
}
context.code('}');
}
};
keywords.allOf = function (context) {
if (!Array.isArray(context.schema.allOf)) {
return;
}
for (var i = 0; i < context.schema.allOf.length; i++) {
context.validate(context.path, context.schema.allOf[i], context.noFailFast);
}
};
keywords.anyOf = function (context) {
if (!Array.isArray(context.schema.anyOf)) {
return;
}
var errCount = context.declare(0),
initialCount = context.declare(0),
found = context.declare(false),
i = 0;
context.code(initialCount + ' = errors.length');
for (; i < context.schema.anyOf.length; i++) {
context.code('if (!' + found + ') {');
context.code(errCount + ' = errors.length');
context.validate(context.path, context.schema.anyOf[i], true);
context.code(found + ' = errors.length === ' + errCount)
('}');
}
context.code('if (!' + found + ') {');
context.error('anyOf');
context.code('} else {')
('errors.length = ' + initialCount)
('}');
};
keywords.oneOf = function (context) {
if (!Array.isArray(context.schema.oneOf)) {
return;
}
var matching = context.declare(0),
initialCount = context.declare(0),
errCount = context.declare(0),
i = 0;
context.code(initialCount + ' = errors.length');
for (; i < context.schema.oneOf.length; i++) {
context.code(errCount + ' = errors.length');
context.validate(context.path, context.schema.oneOf[i], true);
context.code('if (errors.length === ' + errCount + ') {')
(matching + '++')
('}');
}
context.code('if (' + matching + ' !== 1) {');
context.error('oneOf');
context.code('} else {')
('errors.length = ' + initialCount)
('}');
};
keywords.not = function (context) {
if (type(context.schema.not) !== 'object') {
return;
}
var errCount = context.declare(0);
context.code(errCount + ' = errors.length');
context.validate(context.path, context.schema.not, true);
context.code('if (errors.length === ' + errCount + ') {');
context.error('not');
context.code('} else {')
('errors.length = ' + errCount)
('}');
};
['minimum', 'exclusiveMinimum', 'maximum', 'exclusiveMaximum', 'multipleOf']
.forEach(function (keyword) { keywords[keyword].type = 'number'; });
['minLength', 'maxLength', 'pattern', 'format']
.forEach(function (keyword) { keywords[keyword].type = 'string'; });
['minItems', 'maxItems', 'additionalItems', 'uniqueItems', 'items']
.forEach(function (keyword) { keywords[keyword].type = 'array'; });
['maxProperties', 'minProperties', 'required', 'properties', 'patternProperties', 'additionalProperties', 'dependencies']
.forEach(function (keyword) { keywords[keyword].type = 'object'; });
function getGenerators(schema) {
var keys = Object.keys(schema),
start = [],
perType = {},
gen, i;
for (i = 0; i < keys.length; i++) {
gen = keywords[keys[i]];
if (!gen) {
continue;
}
if (gen.type) {
if (!perType[gen.type]) {
perType[gen.type] = [];
}
perType[gen.type].push(gen);
}
else {
start.push(gen);
}
}
return start.concat(Object.keys(perType).reduce(function (arr, key) {
return arr.concat(perType[key]);
}, []));
}
function replaceIndexedProperty(match) {
var index = match.replace(PATH_PROP_REPLACE_EXPR, '$1');
if (!isNaN(+index)) {
// numeric index in array
return '.' + index;
}
else if (index[0] === '"') {
// string key for an object property
return '[\\"' + index.substr(1, index.length - 2) + '\\"]';
}
// variable containing the actual key
return '." + ' + index + ' + "';
}
function getPathExpression(path) {
return '"' + path.replace(PATH_REPLACE_EXPR, replaceIndexedProperty).substr(5) + '"';
}
function clone(obj) {
var cloned = obj,
objType = type(obj),
key, i;
if (objType === 'object') {
cloned = {};
for (key in obj) {
cloned[key] = clone(obj[key]);
}
}
else if (objType === 'array') {
cloned = [];
for (i = 0; i < obj.length; i++) {
cloned[i] = clone(obj[i]);
}
}
else if (objType === 'regexp') {
return new RegExp(obj);
}
else if (objType === 'date') {
return new Date(obj.toJSON());
}
return cloned;
}
function build(schema, def, additional, resolver) {
var defType, defValue, key, i;
if (type(schema) !== 'object') {
return def;
}
schema = resolver.resolve(schema);
if (def === undefined && schema.hasOwnProperty('default')) {
def = clone(schema['default']);
}
defType = type(def);
if (defType === 'object' && type(schema.properties) === 'object') {
for (key in schema.properties) {
defValue = build(schema.properties[key], def[key], additional, resolver);
if (defValue !== undefined) {
def[key] = defValue;
}
}
for (key in def) {
if (!(key in schema.properties) &&
(schema.additionalProperties === false ||
(additional === false && !schema.additionalProperties))) {
delete def[key];
}
}
}
else if (defType === 'array' && schema.items) {
if (type(schema.items) === 'array') {
for (i = 0; i < schema.items.length; i++) {
defValue = build(schema.items[i], def[i], additional, resolver);
if (defValue !== undefined || i < def.length) {
def[i] = defValue;
}
}
}
else if (def.length) {
for (i = 0; i < def.length; i++) {
def[i] = build(schema.items, def[i], additional, resolver);
}
}
}
return def;
}
function jsen(schema, options) {
if (type(schema) !== 'object') {
throw new Error(INVALID_SCHEMA);
}
options = options || {};
var missing$Ref = options.missing$Ref || false,
resolver = new SchemaResolver(schema, options.schemas, missing$Ref),
counter = 0,
id = function () { return 'i' + (counter++); },
funcache = {},
compiled,
refs = {
errors: []
},
scope = {
equal: equal,
unique: unique,
refs: refs
};
function cache(schema) {
var deref = resolver.resolve(schema),
ref = schema.$ref,
cached = funcache[ref],
func;
if (!cached) {
cached = funcache[ref] = {
key: id(),
func: function (data) {
return func(data);
}
};
func = compile(deref);
Object.defineProperty(cached.func, 'errors', {
get: function () {
return func.errors;
}
});
refs[cached.key] = cached.func;
}
return 'refs.' + cached.key;
}
function compile(schema) {
function declare(def) {
var variname = id();
code.def(variname, def);
return variname;
}
function validate(path, schema, noFailFast) {
var context,
cachedRef,
pathExp,
index,
lastType,
format,
gens,
gen,
i;
function error(keyword, key) {
var varid,
errorPath = path,
message = (key && schema.properties && schema.properties[key] && schema.properties[key].requiredMessage) ||
schema.invalidMessage;
if (!message) {
message = key && schema.properties && schema.properties[key] && schema.properties[key].messages &&
schema.properties[key].messages[keyword] ||
schema.messages && schema.messages[keyword];
}
if (path.indexOf('[') > -1) {
// create error objects dynamically when path contains indexed property expressions
errorPath = getPathExpression(path);
if (key) {
errorPath = errorPath ? errorPath + ' + ".' + key + '"' : key;
}
code('errors.push({')
('path: ' + errorPath + ', ')
('keyword: "' + keyword + '"' + (message ? ',' : ''));
if (message) {
code('message: "' + message + '"');
}
code('})');
}
else {
// generate faster code when no indexed properties in the path
varid = id();
errorPath = errorPath.substr(5);
if (key) {
errorPath = errorPath ? errorPath + '.' + key : key;
}
refs[varid] = {
path: errorPath,
keyword: keyword
};
if (message) {
refs[varid].message = message;
}
code('errors.push(refs.' + varid + ')');
}
if (!noFailFast && !options.greedy) {
code('return (validate.errors = errors) && false');
}
}
if (schema.$ref !== undefined) {
cachedRef = cache(schema);
pathExp = getPathExpression(path);
index = declare(0);
code('if (!' + cachedRef + '(' + path + ')) {')
('if (' + cachedRef + '.errors) {')
('errors.push.apply(errors, ' + cachedRef + '.errors)')
('for (' + index + ' = 0; ' + index + ' < ' + cachedRef + '.errors.length; ' + index + '++) {')
('if (' + cachedRef + '.errors[' + index + '].path) {')
('errors[errors.length - ' + cachedRef + '.errors.length + ' + index + '].path = ' + pathExp +
' + "." + ' + cachedRef + '.errors[' + index + '].path')
('} else {')
('errors[errors.length - ' + cachedRef + '.errors.length + ' + index + '].path = ' + pathExp)
('}')
('}')
('}')
('}');
return;
}
context = {
path: path,
schema: schema,
code: code,
declare: declare,
validate: validate,
error: error,
noFailFast: noFailFast
};
gens = getGenerators(schema);
for (i = 0; i < gens.length; i++) {
gen = gens[i];
if (gen.type && lastType !== gen.type) {
if (lastType) {
code('}');
}
lastType = gen.type;
code('if (' + types[gen.type](path) + ') {');
}
gen(context);
}
if (lastType) {
code('}');
}
if (schema.format && options.formats) {
format = options.formats[schema.format];
if (format) {
if (typeof format === 'string' || format instanceof RegExp) {
code('if (!(' + inlineRegex(format) + ').test(' + context.path + ')) {');
error('format');
code('}');
}
else if (typeof format === 'function') {
(scope.formats || (scope.formats = {}))[schema.format] = format;
(scope.schemas || (scope.schemas = {}))[schema.format] = schema;
code('if (!formats["' + schema.format + '"](' + context.path + ', schemas["' + schema.format + '"])) {');
error('format');
code('}');
}
}
}
}
var code = func('validate', 'data')
('var errors = []');
validate('data', schema);
code('return (validate.errors = errors) && errors.length === 0');
compiled = code.compile(scope);
compiled.errors = [];
compiled.build = function (initial, options) {
return build(
schema,
(options && options.copy === false ? initial : clone(initial)),
options && options.additionalProperties,
resolver);
};
return compiled;
}
return compile(schema);
}
jsen.browser = browser;
jsen.clone = clone;
jsen.equal = equal;
jsen.unique = unique;
module.exports = jsen;
},{"./equal.js":2,"./formats.js":3,"./func.js":4,"./resolver.js":7,"./unique.js":8}],6:[function(require,module,exports){
module.exports={
"id": "http://json-schema.org/draft-04/schema#",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Core schema meta-schema",
"definitions": {
"schemaArray": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#" }
},
"positiveInteger": {
"type": "integer",
"minimum": 0
},
"positiveIntegerDefault0": {
"allOf": [ { "$ref": "#/definitions/positiveInteger" }, { "default": 0 } ]
},
"simpleTypes": {
"anyOf": [
{ "enum": [ "array", "boolean", "integer", "null", "number", "object", "string", "any" ] },
{ "type": "string" }
]
},
"stringArray": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"uniqueItems": true
}
},
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uri"
},
"$schema": {
"type": "string",
"format": "uri"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"default": {},
"multipleOf": {
"type": "number",
"minimum": 0,
"exclusiveMinimum": true
},
"maximum": {
"type": "number"
},
"exclusiveMaximum": {
"type": "boolean",
"default": false
},
"minimum": {
"type": "number"
},
"exclusiveMinimum": {
"type": "boolean",
"default": false
},
"maxLength": { "$ref": "#/definitions/positiveInteger" },
"minLength": { "$ref": "#/definitions/positiveIntegerDefault0" },
"pattern": {
"type": "string",
"format": "regex"
},
"additionalItems": {
"anyOf": [
{ "type": "boolean" },
{ "$ref": "#" }
],
"default": {}
},
"items": {
"anyOf": [
{ "$ref": "#" },
{ "$ref": "#/definitions/schemaArray" }
],
"default": {}
},
"maxItems": { "$ref": "#/definitions/positiveInteger" },
"minItems": { "$ref": "#/definitions/positiveIntegerDefault0" },
"uniqueItems": {
"type": "boolean",
"default": false
},
"maxProperties": { "$ref": "#/definitions/positiveInteger" },
"minProperties": { "$ref": "#/definitions/positiveIntegerDefault0" },
"required": { "$ref": "#/definitions/stringArray" },
"additionalProperties": {
"anyOf": [
{ "type": "boolean" },
{ "$ref": "#" }
],
"default": {}
},
"definitions": {
"type": "object",
"additionalProperties": { "$ref": "#" },
"default": {}
},
"properties": {
"type": "object",
"additionalProperties": { "$ref": "#" },
"default": {}
},
"patternProperties": {
"type": "object",
"additionalProperties": { "$ref": "#" },
"default": {}
},
"dependencies": {
"type": "object",
"additionalProperties": {
"anyOf": [
{ "$ref": "#" },
{ "$ref": "#/definitions/stringArray" }
]
}
},
"enum": {
"type": "array",
"minItems": 1,
"uniqueItems": true
},
"type": {
"anyOf": [
{ "$ref": "#/definitions/simpleTypes" },
{
"type": "array",
"items": { "$ref": "#/definitions/simpleTypes" },
"minItems": 1,
"uniqueItems": true
}
]
},
"allOf": { "$ref": "#/definitions/schemaArray" },
"anyOf": { "$ref": "#/definitions/schemaArray" },
"oneOf": { "$ref": "#/definitions/schemaArray" },
"not": { "$ref": "#" }
},
"dependencies": {
"exclusiveMaximum": [ "maximum" ],
"exclusiveMinimum": [ "minimum" ]
},
"default": {}
}
},{}],7:[function(require,module,exports){
'use strict';
var metaschema = require('./metaschema.json'),
refRegex = /#?(\/?\w+)*$/,
INVALID_SCHEMA_REFERENCE = 'jsen: invalid schema reference';
function get(obj, key) {
var parts = key.split('.'),
subobj,
remaining;
if (parts.length === 1) {
// simple key
return obj[key];
}
// compound and nested properties
// e.g. key('nested.key', { nested: { key: 123 } }) === 123
// e.g. key('compount.key', { 'compound.key': 456 }) === 456
while (parts.length && obj !== undefined && obj !== null) {
// take a part from the front
remaining = parts.slice(0);
subobj = undefined;
// try to match larger compound keys containing dots
while (remaining.length && subobj === undefined) {
subobj = obj[remaining.join('.')];
if (subobj === undefined) {
remaining.pop();
}
}
// if there is a matching larger compount key, use that
if (subobj !== undefined) {
obj = subobj;
// remove keys from the parts, respectively
while (remaining.length) {
remaining.shift();
parts.shift();
}
}
else {
// treat like normal simple keys
obj = obj[parts.shift()];
}
}
return obj;
}
// http://tools.ietf.org/html/draft-ietf-appsawg-json-pointer-08#section-3
function unescape(pointer) {
return decodeURIComponent(pointer)
.replace(/~1/g, '/')
.replace(/~0/g, '~');
}
function refToPath(ref) {
if (ref.indexOf('#') < 0) {
return ref;
}
var path = ref.split('#')[1];
if (path) {
path = path
.split('/')
.map(unescape)
.join('.');
if (path[0] === '.') {
path = path.substr(1);
}
}
return path;
}
function refFromId(obj, ref) {
if (obj && typeof obj === 'object') {
if (obj.id === ref) {
return obj;
}
return Object.keys(obj).reduce(function (resolved, key) {
return resolved || refFromId(obj[key], ref);
}, undefined);
}
return undefined;
}
function getResolvers(schemas) {
var keys = Object.keys(schemas),
resolvers = {},
key, i;
for (i = 0; i < keys.length; i++) {
key = keys[i];
resolvers[key] = new SchemaResolver(schemas[key]);
}
return resolvers;
}
function SchemaResolver(rootSchema, external, missing$Ref) { // jshint ignore: line
this.rootSchema = rootSchema;
this.cache = {};
this.resolved = null;
this.missing$Ref = missing$Ref;
this.resolvers = external && typeof external === 'object' ?
getResolvers(external) :
null;
}
SchemaResolver.prototype.resolveRef = function (ref) {
var err = new Error(INVALID_SCHEMA_REFERENCE + ' ' + ref),
root = this.rootSchema,
externalResolver,
path,
dest;
if (!ref || typeof ref !== 'string' || !refRegex.test(ref)) {
throw err;
}
if (ref === metaschema.id) {
dest = metaschema;
}
if (!dest) {
dest = refFromId(root, ref);
}
if (!dest) {
path = refToPath(ref);
dest = path ? get(root, path) : root;
}
if (!dest && path && this.resolvers) {
externalResolver = get(this.resolvers, path);
if (externalResolver) {
dest = externalResolver.resolve(externalResolver.rootSchema);
}
}
if (!dest || typeof dest !== 'object') {
if (this.missing$Ref) {
dest = {};
} else {
throw err;
}
}
if (this.cache[ref] === dest) {
return dest;
}
this.cache[ref] = dest;
if (dest.$ref !== undefined) {
dest = this.cache[ref] = this.resolveRef(dest.$ref);
}
return dest;
};
SchemaResolver.prototype.resolve = function (schema) {
if (!schema || typeof schema !== 'object') {
return schema;
}
var ref = schema.$ref,
resolved = this.cache[ref];
if (ref === undefined) {
return schema;
}
if (resolved) {
return resolved;
}
resolved = this.resolveRef(ref);
if (schema === this.rootSchema && schema !== resolved) {
// substitute the resolved root schema
this.rootSchema = resolved;
}
return resolved;
};
module.exports = SchemaResolver;
},{"./metaschema.json":6}],8:[function(require,module,exports){
'use strict';
var equal = require('./equal.js');
function findIndex(arr, value, comparator) {
for (var i = 0, len = arr.length; i < len; i++) {
if (comparator(arr[i], value)) {
return i;
}
}
return -1;
}
module.exports = function unique(arr) {
return arr.filter(function uniqueOnly(value, index, self) {
return findIndex(self, value, equal) === index;
});
};
module.exports.findIndex = findIndex;
},{"./equal.js":2}]},{},[1])(1)
});
/**
* @license MIT
* @fileOverview Favico animations
* @author Miroslav Magda, http://blog.ejci.net
* @version 0.3.9
*/
/**
* Create new favico instance
* @param {Object} Options
* @return {Object} Favico object
* @example
* var favico = new Favico({
* bgColor : '#d00',
* textColor : '#fff',
* fontFamily : 'sans-serif',
* fontStyle : 'bold',
* position : 'down',
* type : 'circle',
* animation : 'slide',
* dataUrl: function(url){},
* win: top
* });
*/
(function() {
var Favico = (function(opt) {
'use strict';
opt = (opt) ? opt : {};
var _def = {
bgColor : '#d00',
textColor : '#fff',
fontFamily : 'sans-serif', //Arial,Verdana,Times New Roman,serif,sans-serif,...
fontStyle : 'bold', //normal,italic,oblique,bold,bolder,lighter,100,200,300,400,500,600,700,800,900
type : 'circle',
position : 'down', // down, up, left, leftup (upleft)
animation : 'slide',
elementId : false,
dataUrl : false,
win: window
};
var _opt, _orig, _h, _w, _canvas, _context, _img, _ready, _lastBadge, _running, _readyCb, _stop, _browser, _animTimeout, _drawTimeout, _doc;
_browser = {};
_browser.ff = typeof InstallTrigger != 'undefined';
_browser.chrome = !!window.chrome;
_browser.opera = !!window.opera || navigator.userAgent.indexOf('Opera') >= 0;
_browser.ie = /*@cc_on!@*/false;
_browser.safari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0;
_browser.supported = (_browser.chrome || _browser.ff || _browser.opera);
var _queue = [];
_readyCb = function() {
};
_ready = _stop = false;
/**
* Initialize favico
*/
var init = function() {
//merge initial options
_opt = merge(_def, opt);
_opt.bgColor = hexToRgb(_opt.bgColor);
_opt.textColor = hexToRgb(_opt.textColor);
_opt.position = _opt.position.toLowerCase();
_opt.animation = (animation.types['' + _opt.animation]) ? _opt.animation : _def.animation;
_doc = _opt.win.document;
var isUp = _opt.position.indexOf('up') > -1;
var isLeft = _opt.position.indexOf('left') > -1;
//transform animation
if (isUp || isLeft) {
for (var i = 0; i < animation.types['' + _opt.animation].length; i++) {
var step = animation.types['' + _opt.animation][i];
if (isUp) {
if (step.y < 0.6) {
step.y = step.y - 0.4;
} else {
step.y = step.y - 2 * step.y + (1 - step.w);
}
}
if (isLeft) {
if (step.x < 0.6) {
step.x = step.x - 0.4;
} else {
step.x = step.x - 2 * step.x + (1 - step.h);
}
}
animation.types['' + _opt.animation][i] = step;
}
}
_opt.type = (type['' + _opt.type]) ? _opt.type : _def.type;
_orig = link.getIcon();
//create temp canvas
_canvas = document.createElement('canvas');
//create temp image
_img = document.createElement('img');
if (_orig.hasAttribute('href')) {
_img.setAttribute('crossOrigin', 'anonymous');
_img.setAttribute('src', _orig.getAttribute('href'));
//get width/height
_img.onload = function() {
_h = (_img.height > 0) ? _img.height : 32;
_w = (_img.width > 0) ? _img.width : 32;
_canvas.height = _h;
_canvas.width = _w;
_context = _canvas.getContext('2d');
icon.ready();
};
} else {
_img.setAttribute('src', '');
_h = 32;
_w = 32;
_img.height = _h;
_img.width = _w;
_canvas.height = _h;
_canvas.width = _w;
_context = _canvas.getContext('2d');
icon.ready();
}
};
/**
* Icon namespace
*/
var icon = {};
/**
* Icon is ready (reset icon) and start animation (if ther is any)
*/
icon.ready = function() {
_ready = true;
icon.reset();
_readyCb();
};
/**
* Reset icon to default state
*/
icon.reset = function() {
//reset
if (!_ready) {
return;
}
_queue = [];
_lastBadge = false;
_running = false;
_context.clearRect(0, 0, _w, _h);
_context.drawImage(_img, 0, 0, _w, _h);
//_stop=true;
link.setIcon(_canvas);
//webcam('stop');
//video('stop');
window.clearTimeout(_animTimeout);
window.clearTimeout(_drawTimeout);
};
/**
* Start animation
*/
icon.start = function() {
if (!_ready || _running) {
return;
}
var finished = function() {
_lastBadge = _queue[0];
_running = false;
if (_queue.length > 0) {
_queue.shift();
icon.start();
} else {
}
};
if (_queue.length > 0) {
_running = true;
var run = function() {
// apply options for this animation
['type', 'animation', 'bgColor', 'textColor', 'fontFamily', 'fontStyle'].forEach(function(a) {
if ( a in _queue[0].options) {
_opt[a] = _queue[0].options[a];
}
});
animation.run(_queue[0].options, function() {
finished();
}, false);
};
if (_lastBadge) {
animation.run(_lastBadge.options, function() {
run();
}, true);
} else {
run();
}
}
};
/**
* Badge types
*/
var type = {};
var options = function(opt) {
opt.n = (( typeof opt.n) === 'number') ? Math.abs(opt.n | 0) : opt.n;
opt.x = _w * opt.x;
opt.y = _h * opt.y;
opt.w = _w * opt.w;
opt.h = _h * opt.h;
opt.len = ("" + opt.n).length;
return opt;
};
/**
* Generate circle
* @param {Object} opt Badge options
*/
type.circle = function(opt) {
opt = options(opt);
var more = false;
if (opt.len === 2) {
opt.x = opt.x - opt.w * 0.4;
opt.w = opt.w * 1.4;
more = true;
} else if (opt.len >= 3) {
opt.x = opt.x - opt.w * 0.65;
opt.w = opt.w * 1.65;
more = true;
}
_context.clearRect(0, 0, _w, _h);
_context.drawImage(_img, 0, 0, _w, _h);
_context.beginPath();
_context.font = _opt.fontStyle + " " + Math.floor(opt.h * (opt.n > 99 ? 0.85 : 1)) + "px " + _opt.fontFamily;
_context.textAlign = 'center';
if (more) {
_context.moveTo(opt.x + opt.w / 2, opt.y);
_context.lineTo(opt.x + opt.w - opt.h / 2, opt.y);
_context.quadraticCurveTo(opt.x + opt.w, opt.y, opt.x + opt.w, opt.y + opt.h / 2);
_context.lineTo(opt.x + opt.w, opt.y + opt.h - opt.h / 2);
_context.quadraticCurveTo(opt.x + opt.w, opt.y + opt.h, opt.x + opt.w - opt.h / 2, opt.y + opt.h);
_context.lineTo(opt.x + opt.h / 2, opt.y + opt.h);
_context.quadraticCurveTo(opt.x, opt.y + opt.h, opt.x, opt.y + opt.h - opt.h / 2);
_context.lineTo(opt.x, opt.y + opt.h / 2);
_context.quadraticCurveTo(opt.x, opt.y, opt.x + opt.h / 2, opt.y);
} else {
_context.arc(opt.x + opt.w / 2, opt.y + opt.h / 2, opt.h / 2, 0, 2 * Math.PI);
}
_context.fillStyle = 'rgba(' + _opt.bgColor.r + ',' + _opt.bgColor.g + ',' + _opt.bgColor.b + ',' + opt.o + ')';
_context.fill();
_context.closePath();
_context.beginPath();
_context.stroke();
_context.fillStyle = 'rgba(' + _opt.textColor.r + ',' + _opt.textColor.g + ',' + _opt.textColor.b + ',' + opt.o + ')';
//_context.fillText((more) ? '9+' : opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15));
if (( typeof opt.n) === 'number' && opt.n > 999) {
_context.fillText(((opt.n > 9999) ? 9 : Math.floor(opt.n / 1000) ) + 'k+', Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2));
} else {
_context.fillText(opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15));
}
_context.closePath();
};
/**
* Generate rectangle
* @param {Object} opt Badge options
*/
type.rectangle = function(opt) {
opt = options(opt);
var more = false;
if (opt.len === 2) {
opt.x = opt.x - opt.w * 0.4;
opt.w = opt.w * 1.4;
more = true;
} else if (opt.len >= 3) {
opt.x = opt.x - opt.w * 0.65;
opt.w = opt.w * 1.65;
more = true;
}
_context.clearRect(0, 0, _w, _h);
_context.drawImage(_img, 0, 0, _w, _h);
_context.beginPath();
_context.font = _opt.fontStyle + " " + Math.floor(opt.h * (opt.n > 99 ? 0.9 : 1)) + "px " + _opt.fontFamily;
_context.textAlign = 'center';
_context.fillStyle = 'rgba(' + _opt.bgColor.r + ',' + _opt.bgColor.g + ',' + _opt.bgColor.b + ',' + opt.o + ')';
_context.fillRect(opt.x, opt.y, opt.w, opt.h);
_context.fillStyle = 'rgba(' + _opt.textColor.r + ',' + _opt.textColor.g + ',' + _opt.textColor.b + ',' + opt.o + ')';
//_context.fillText((more) ? '9+' : opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15));
if (( typeof opt.n) === 'number' && opt.n > 999) {
_context.fillText(((opt.n > 9999) ? 9 : Math.floor(opt.n / 1000) ) + 'k+', Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2));
} else {
_context.fillText(opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15));
}
_context.closePath();
};
/**
* Set badge
*/
var badge = function(number, opts) {
opts = (( typeof opts) === 'string' ? {
animation : opts
} : opts) || {};
_readyCb = function() {
try {
if ( typeof (number) === 'number' ? (number > 0) : (number !== '')) {
var q = {
type : 'badge',
options : {
n : number
}
};
if ('animation' in opts && animation.types['' + opts.animation]) {
q.options.animation = '' + opts.animation;
}
if ('type' in opts && type['' + opts.type]) {
q.options.type = '' + opts.type;
}
['bgColor', 'textColor'].forEach(function(o) {
if ( o in opts) {
q.options[o] = hexToRgb(opts[o]);
}
});
['fontStyle', 'fontFamily'].forEach(function(o) {
if ( o in opts) {
q.options[o] = opts[o];
}
});
_queue.push(q);
if (_queue.length > 100) {
throw new Error('Too many badges requests in queue.');
}
icon.start();
} else {
icon.reset();
}
} catch(e) {
throw new Error('Error setting badge. Message: ' + e.message);
}
};
if (_ready) {
_readyCb();
}
};
/**
* Set image as icon
*/
var image = function(imageElement) {
_readyCb = function() {
try {
var w = imageElement.width;
var h = imageElement.height;
var newImg = document.createElement('img');
var ratio = (w / _w < h / _h) ? (w / _w) : (h / _h);
newImg.setAttribute('crossOrigin', 'anonymous');
newImg.setAttribute('src', imageElement.getAttribute('src'));
newImg.height = (h / ratio);
newImg.width = (w / ratio);
_context.clearRect(0, 0, _w, _h);
_context.drawImage(newImg, 0, 0, _w, _h);
link.setIcon(_canvas);
} catch(e) {
throw new Error('Error setting image. Message: ' + e.message);
}
};
if (_ready) {
_readyCb();
}
};
/**
* Set video as icon
*/
var video = function(videoElement) {
_readyCb = function() {
try {
if (videoElement === 'stop') {
_stop = true;
icon.reset();
_stop = false;
return;
}
//var w = videoElement.width;
//var h = videoElement.height;
//var ratio = (w / _w < h / _h) ? (w / _w) : (h / _h);
videoElement.addEventListener('play', function() {
drawVideo(this);
}, false);
} catch(e) {
throw new Error('Error setting video. Message: ' + e.message);
}
};
if (_ready) {
_readyCb();
}
};
/**
* Set video as icon
*/
var webcam = function(action) {
//UR
if (!window.URL || !window.URL.createObjectURL) {
window.URL = window.URL || {};
window.URL.createObjectURL = function(obj) {
return obj;
};
}
if (_browser.supported) {
var newVideo = false;
navigator.getUserMedia = navigator.getUserMedia || navigator.oGetUserMedia || navigator.msGetUserMedia || navigator.mozGetUserMedia || navigator.webkitGetUserMedia;
_readyCb = function() {
try {
if (action === 'stop') {
_stop = true;
icon.reset();
_stop = false;
return;
}
newVideo = document.createElement('video');
newVideo.width = _w;
newVideo.height = _h;
navigator.getUserMedia({
video : true,
audio : false
}, function(stream) {
newVideo.src = URL.createObjectURL(stream);
newVideo.play();
drawVideo(newVideo);
}, function() {
});
} catch(e) {
throw new Error('Error setting webcam. Message: ' + e.message);
}
};
if (_ready) {
_readyCb();
}
}
};
/**
* Draw video to context and repeat :)
*/
function drawVideo(video) {
if (video.paused || video.ended || _stop) {
return false;
}
//nasty hack for FF webcam (Thanks to Julian Ćwirko, kontakt@redsunmedia.pl)
try {
_context.clearRect(0, 0, _w, _h);
_context.drawImage(video, 0, 0, _w, _h);
} catch(e) {
}
_drawTimeout = setTimeout(function() {
drawVideo(video);
}, animation.duration);
link.setIcon(_canvas);
}
var link = {};
/**
* Get icon from HEAD tag or create a new <link> element
*/
link.getIcon = function() {
var elm = false;
//get link element
var getLink = function() {
var link = _doc.getElementsByTagName('head')[0].getElementsByTagName('link');
for (var l = link.length, i = (l - 1); i >= 0; i--) {
if ((/(^|\s)icon(\s|$)/i).test(link[i].getAttribute('rel'))) {
return link[i];
}
}
return false;
};
if (_opt.element) {
elm = _opt.element;
} else if (_opt.elementId) {
//if img element identified by elementId
elm = _doc.getElementById(_opt.elementId);
elm.setAttribute('href', elm.getAttribute('src'));
} else {
//if link element
elm = getLink();
if (elm === false) {
elm = _doc.createElement('link');
elm.setAttribute('rel', 'icon');
_doc.getElementsByTagName('head')[0].appendChild(elm);
}
}
elm.setAttribute('type', 'image/png');
return elm;
};
link.setIcon = function(canvas) {
var url = canvas.toDataURL('image/png');
if (_opt.dataUrl) {
//if using custom exporter
_opt.dataUrl(url);
}
if (_opt.element) {
_opt.element.setAttribute('href', url);
_opt.element.setAttribute('src', url);
} else if (_opt.elementId) {
//if is attached to element (image)
var elm = _doc.getElementById(_opt.elementId);
elm.setAttribute('href', url);
elm.setAttribute('src', url);
} else {
//if is attached to fav icon
if (_browser.ff || _browser.opera) {
//for FF we need to "recreate" element, atach to dom and remove old <link>
//var originalType = _orig.getAttribute('rel');
var old = _orig;
_orig = _doc.createElement('link');
//_orig.setAttribute('rel', originalType);
if (_browser.opera) {
_orig.setAttribute('rel', 'icon');
}
_orig.setAttribute('rel', 'icon');
_orig.setAttribute('type', 'image/png');
_doc.getElementsByTagName('head')[0].appendChild(_orig);
_orig.setAttribute('href', url);
if (old.parentNode) {
old.parentNode.removeChild(old);
}
} else {
_orig.setAttribute('href', url);
}
}
};
//http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb#answer-5624139
//HEX to RGB convertor
function hexToRgb(hex) {
var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, function(m, r, g, b) {
return r + r + g + g + b + b;
});
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r : parseInt(result[1], 16),
g : parseInt(result[2], 16),
b : parseInt(result[3], 16)
} : false;
}
/**
* Merge options
*/
function merge(def, opt) {
var mergedOpt = {};
var attrname;
for (attrname in def) {
mergedOpt[attrname] = def[attrname];
}
for (attrname in opt) {
mergedOpt[attrname] = opt[attrname];
}
return mergedOpt;
}
/**
* Cross-browser page visibility shim
* http://stackoverflow.com/questions/12536562/detect-whether-a-window-is-visible
*/
function isPageHidden() {
return _doc.hidden || _doc.msHidden || _doc.webkitHidden || _doc.mozHidden;
}
/**
* @namespace animation
*/
var animation = {};
/**
* Animation "frame" duration
*/
animation.duration = 40;
/**
* Animation types (none,fade,pop,slide)
*/
animation.types = {};
animation.types.fade = [{
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 0.0
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 0.1
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 0.2
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 0.3
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 0.4
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 0.5
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 0.6
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 0.7
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 0.8
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 0.9
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 1.0
}];
animation.types.none = [{
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 1
}];
animation.types.pop = [{
x : 1,
y : 1,
w : 0,
h : 0,
o : 1
}, {
x : 0.9,
y : 0.9,
w : 0.1,
h : 0.1,
o : 1
}, {
x : 0.8,
y : 0.8,
w : 0.2,
h : 0.2,
o : 1
}, {
x : 0.7,
y : 0.7,
w : 0.3,
h : 0.3,
o : 1
}, {
x : 0.6,
y : 0.6,
w : 0.4,
h : 0.4,
o : 1
}, {
x : 0.5,
y : 0.5,
w : 0.5,
h : 0.5,
o : 1
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 1
}];
animation.types.popFade = [{
x : 0.75,
y : 0.75,
w : 0,
h : 0,
o : 0
}, {
x : 0.65,
y : 0.65,
w : 0.1,
h : 0.1,
o : 0.2
}, {
x : 0.6,
y : 0.6,
w : 0.2,
h : 0.2,
o : 0.4
}, {
x : 0.55,
y : 0.55,
w : 0.3,
h : 0.3,
o : 0.6
}, {
x : 0.50,
y : 0.50,
w : 0.4,
h : 0.4,
o : 0.8
}, {
x : 0.45,
y : 0.45,
w : 0.5,
h : 0.5,
o : 0.9
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 1
}];
animation.types.slide = [{
x : 0.4,
y : 1,
w : 0.6,
h : 0.6,
o : 1
}, {
x : 0.4,
y : 0.9,
w : 0.6,
h : 0.6,
o : 1
}, {
x : 0.4,
y : 0.9,
w : 0.6,
h : 0.6,
o : 1
}, {
x : 0.4,
y : 0.8,
w : 0.6,
h : 0.6,
o : 1
}, {
x : 0.4,
y : 0.7,
w : 0.6,
h : 0.6,
o : 1
}, {
x : 0.4,
y : 0.6,
w : 0.6,
h : 0.6,
o : 1
}, {
x : 0.4,
y : 0.5,
w : 0.6,
h : 0.6,
o : 1
}, {
x : 0.4,
y : 0.4,
w : 0.6,
h : 0.6,
o : 1
}];
/**
* Run animation
* @param {Object} opt Animation options
* @param {Object} cb Callabak after all steps are done
* @param {Object} revert Reverse order? true|false
* @param {Object} step Optional step number (frame bumber)
*/
animation.run = function(opt, cb, revert, step) {
var animationType = animation.types[isPageHidden() ? 'none' : _opt.animation];
if (revert === true) {
step = ( typeof step !== 'undefined') ? step : animationType.length - 1;
} else {
step = ( typeof step !== 'undefined') ? step : 0;
}
cb = (cb) ? cb : function() {
};
if ((step < animationType.length) && (step >= 0)) {
type[_opt.type](merge(opt, animationType[step]));
_animTimeout = setTimeout(function() {
if (revert) {
step = step - 1;
} else {
step = step + 1;
}
animation.run(opt, cb, revert, step);
}, animation.duration);
link.setIcon(_canvas);
} else {
cb();
return;
}
};
//auto init
init();
return {
badge : badge,
video : video,
image : image,
webcam : webcam,
reset : icon.reset,
browser : {
supported : _browser.supported
}
};
});
// AMD / RequireJS
if ( typeof define !== 'undefined' && define.amd) {
define([], function() {
return Favico;
});
}
// CommonJS
else if ( typeof module !== 'undefined' && module.exports) {
module.exports = Favico;
}
// included directly via <script> tag
else {
window.Favico = Favico;
}
})();
}
})();