OSDN Git Service

modified version description.
[feedblog/feedblog_ext.git] / js / lunardial / feedblog_ext_search.js
1 /**
2  * FeedBlog SearchScript Ext Version
3  *
4  * @copyright 2009 FeedBlog Project (http://sourceforge.jp/projects/feedblog/)
5  * @author Kureha Hisame (http://lunardial.sakura.ne.jp/) & Yui Naruse (http://airemix.com/)
6  * @since 2009/01/08
7  * @version 3.1.1.0
8  */
9 // ブログ本体のHTMLファイルの名前を記入してください
10 var blogUrl = "./index.html"
11
12 // ログのリストが書かれたXMLのファイルパスを記入してください
13 var logXmlUrl = "./xml/loglist.xml";
14
15 // Ext jsパネルのサイズを記述してください
16 var extPanelWidth = 750;
17
18 // 結果表示エリアの幅のサイズを指定してください
19 var resultAreaWidth = 784;
20
21 // 検索フォームの幅のサイズを記述してください
22 var searchPanelWidth = 774;
23
24 // ログを表示するコンボボックスのサイズを記述してください
25 var extComboWidth = 150;
26
27 // 日記間のスパン(間隔)をPIXEL単位で記述してください
28 var entrySpan = 3;
29
30 /**
31  * XMLファイルから読み込んだファイルのバリデートモードを選択します。
32  * 0 = 改行コード部分に<br/>を挿入
33  * 1 = 改行コード部分に<br/>を挿入しない
34  */
35 var validateMode = "1";
36
37 // 現在の検索語のキャッシュ
38 var currentSearchWords;
39
40 // fetchEntries 用のセマフォ
41 var fetchEntriesSemaphore = new Semaphore();
42
43 // ログのファイルリストを格納するグローバル変数です
44 var logData;
45
46 // コンボボックスのオブジェクトを格納するグローバル変数です
47 var comboBox;
48
49 // 一画面あたりの表示日記数です
50 var showLength = 5;
51
52 // 検索結果をメモリ上に保持する変数です
53 var loadedEntries;
54
55 /**
56  * Extへのイベント登録です。すべてのDOMが利用可能になった時点で実行されます。
57  */
58 $(document).ready(function(){
59     generateForm();
60     
61     // テキストボックスをExt js化し、空欄入力を拒否します
62     var searchTextBox = new Ext.form.TextField({
63         applyTo: "searchWord",
64         allowBlank: false
65     });
66     
67     // XMLのデータをロードします
68     logData = logXMLLoader();
69 });
70
71 /**
72  * 日記の描画を行います。この部分を編集することでデザインを変更可能です。
73  * @param {String} title パネルのタイトル部分に表示する文字列
74  * @param {String} drawitem パネルの本文を格納したDIV要素のid
75  * @param {String} renderto 「タイトル・更新日時・本文」の1日分の日記データを焼き付けるDIV要素のid
76  * @param {String} closed (Ext jsパネルオプション)日記をクローズ状態で生成するか否か
77  */
78 function generatePanel(title, drawitem, renderto, closed){
79     // Ext jsパネルを生成する
80     new Ext.Panel({
81         contentEl: drawitem,
82         width: extPanelWidth,
83         title: title,
84         hideCollapseTool: false,
85         titleCollapse: true,
86         collapsible: true,
87         collapsed: closed,
88         renderTo: renderto
89     });
90 }
91
92 /**
93  * 検索フォーム及び結果表示フォームを生成するメソッドです。
94  */
95 function generateForm(){
96     var formBuffer = "<table align='center'><tbody><tr><td class='searchform' style='width: " + searchPanelWidth + "px'>" +
97     "<form name='searchForm' onsubmit='searchDiary(); return false'>▼ 検索語句を入力してください (語句を半角で区切るとAND、|で区切るとORで検索します)<br/>" +
98     "<input type='text' id='searchWord' style='width: " +
99     (searchPanelWidth * 0.7) +
100     "px;'> <input type='submit' name='execSearch' value='検索'><br/>" +
101     "<input type='checkbox' id='regexpOptionI' checked='checked'/><label for='regexpOptionI'>大文字、小文字を区別しない</label><br/>" +
102     "<input type='checkbox' id='isAsyncOn'/><label for='isAsyncOn'>非同期通信モードで検索を行う</label><br/>" +
103     "<span style='font-weight: bold;'>[ 注意 ]</span>非同期通信モードをオンにすると速度は上昇しますが、検索の順序が保障されません。<br/><br/>" +
104     "▼ 検索対象ログ選択<br/><div id='logSelecter'/></div><input type='checkbox' id='allSearchCheck' checked='checked'/><label for='allSearchCheck'>すべてのログに対して検索を行う</label>" +
105     "<br/><a href='" +
106     blogUrl +
107     "'>トップページへ戻る</a><br/></form></td></tr></tbody></table>"
108     document.getElementById("genForm").innerHTML = formBuffer;
109     
110     var resultAreaBuffer = "<table align='center'><tbody><tr><td class='resultarea' style='width: " + resultAreaWidth + "px;'>" +
111     "<div id='resultWriteArea'><b>検索結果</b></div></td></tr><tr><td class='resultarea' style='width: " +
112     resultAreaWidth +
113     "px;'>" +
114     "<table align='center'><tbody><tr><td><div id='writeArea'></div></td></tr>" +
115     "</tbody></table></td></tr><tr><td><div id='pagerAreaBottom'></div></td></tr></tbody></table>"
116     
117     document.getElementById("resultArea").innerHTML = resultAreaBuffer;
118 }
119
120 /**
121  * 記事クラス
122  * @param {Object} obj entry 要素の DOM オブジェクト
123  */
124 function Entry(obj){
125     this.title = $("title:first", obj).text();
126     if (this.title == "") 
127         requiredElementError(obj, "title");
128     this.title = "<span>" + validateText(this.title) + "</span>";
129     this.content = $("content:first", obj).text();
130     this.content = "<span>" + validateText(this.content) + "</span>";
131     this.id = $("id:first", obj).text();
132     if (this.id == "") 
133         requiredElementError(obj, "id");
134     this.date = $("updated:first", obj).text();
135     if (this.date == "") 
136         requiredElementError(obj, "updated");
137     this.date = validateData(this.date);
138 }
139
140 /**
141  * 記事内が単語群を全て含んでいるか
142  * @param {Array} keywords 単語群
143  * @param {String} regexpType 正規表現の検索モードを示す文字列
144  * @return {boolean} bool 全て含んでいれば true、さもなくば false
145  */
146 Entry.prototype.hasKeywords = function(keywords, regexpType){
147     // 正規表現が一致するかという判定"のみ"を行います
148     for (var i = 0; i < keywords.length; i++) {
149         // 正規表現チェック用のオブジェクトを用意します(OR条件は一時的に条件を置換)
150         var reg = new RegExp('(?:' + keywords[i] + ')(?![^<>]*>)', regexpType);
151         // 一致しなかったらその時点で脱出
152         if (!reg.test(this.content) && !reg.test(this.title)) 
153             return false;
154     }
155     return true;
156 }
157
158 /**
159  * 呼び出すとDIV:id名:writeArea上のHTMLを削除し、ロードエフェクトを表示します
160  */
161 function loadingEffect(){
162     document.getElementById("writeArea").innerHTML = '<div id="drawPanel"><div id="drawItem" class="code" style="text-align: center;"><\/div><\/div>';
163     document.getElementById("drawItem").innerHTML = '<br/><img src="./js/ext/resources/images/default/shared/blue-loading.gif"><br/>長時間画面が切り替わらない場合はページをリロードしてください。<br/><br/>';
164     
165     // ロード表示用のパネルを生成
166     generatePanel("Now Loading .....", "drawItem", "drawPanel", false);
167 }
168
169 /**
170  * 日記データのエラー時の処理を行います
171  */
172 function showError(){
173     document.getElementById("writeArea").innerHTML = '<div id="drawPanel"><div id="drawItem" class="code" style="text-align: center;"><\/div><\/div>';
174     document.getElementById("drawItem").innerHTML = '<br/>日記ファイルのロードに失敗しました!<br/><br/>';
175     
176     // エラー内容をパネルに描画
177     generatePanel("Error!", "drawItem", "drawPanel", false);
178     
179     Ext.Msg.alert("Error!", "日記ファイルが読み込めません!");
180 }
181
182 /**
183  * 日記データのエラー時の処理を行います
184  */
185 function notFoundError(){
186     document.getElementById("writeArea").innerHTML = '<div id="drawPanel"><div id="drawItem" class="code" style="text-align: center;"><\/div><\/div>';
187     document.getElementById("drawItem").innerHTML = '<br/>検索条件に一致する日記は見つかりませんでした。<br/><br/>';
188     
189     // エラー内容をパネルに描画
190     generatePanel("Not Found!", "drawItem", "drawPanel", false);
191 }
192
193 /**
194  * 日記データのエラー時の処理を行います
195  */
196 function requiredElementError(parent, name){
197     Ext.Msg.alert("Error!", parent.ownerDocument.URL + ": 必須な要素 " +
198     name +
199     " が存在しないか空な " +
200     parent.tagName +
201     " 要素が存在します");
202 }
203
204 /**
205  * 日付のHTML表示用バリデーション処理を行います
206  * @param {String} data RFC3339形式のdate-time文字列
207  */
208 function validateData(data){
209     data = data.replace(/T/g, " ");
210     
211     // 秒数の小数点以下の部分はカットする
212     data = data.substring(0, 19);
213     
214     return data;
215 }
216
217 /**
218  * 日記本文のバリデーション処理を行います
219  * @param {String} contents 日記の本文が格納されている文字列
220  */
221 function validateText(contents){
222     // <br/>タグを挿入する
223     if (validateMode == 0) {
224         contents = contents.replace(/[\n\r]|\r\n/g, "<br />");
225     }
226     
227     return contents;
228 }
229
230 /**
231  * XML用に要素をエスケープします
232  * @param {String} str エスケープを行いたい文字列
233  */
234 function xmlAttrContentEscape(str){
235     // return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
236     return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/^[ ]+/mg, "&nbsp;").replace(/^[\t]+/mg, "");
237 }
238
239 /**
240  * 日記本文に日付を付加します
241  * @param {String} contents 日記の本文が格納されている文字列
242  * @param {String} id 日記の初公開日を示す日付文字列
243  */
244 function contentsWithid(contents, id){
245     // リンク用文末作成
246     var hashTag = '<br/><div style="text-align: right;"><a href="' +
247     xmlAttrContentEscape(blogUrl) +
248     '#' +
249     xmlAttrContentEscape(id) +
250     '" target="_blank">- この日の記事にリンクする -<\/a><\/span>';
251     return contents + hashTag;
252 }
253
254 /**
255  * 強調タグを追加します
256  * @param {String} word 強調したい語句
257  */
258 function emphasizeWord(word){
259     return '<span style="background-color: red;">' + word + '</span>';
260 }
261
262 /**
263  * 長い順に並べるための比較関数です
264  * @param {String} a 比較対象(1)
265  * @param {String} b 比較対象(2)
266  */
267 function compareLengthDecrease(a, b){
268     a = a.length;
269     b = b.length;
270     return a > b ? -1 : a < b ? 1 : 0;
271 }
272
273 /**
274  * セマフォ制御用のオブジェクトです
275  */
276 function Semaphore(){
277     this.id = null;
278     this.count = 0;
279     this.buf = [];
280     this.xhrs = [];
281 }
282
283 /**
284  * セマフォ初期化用の関数です
285  */
286 Semaphore.prototype.init = function(){
287     while (this.xhrs.length > 0) {
288         this.xhrs.shift().abort();
289     }
290     this.id = Math.random();
291     this.count = 0;
292     this.buf = [];
293 }
294
295 /**
296  * ログファイル選択用のコンボボックスをid名:logSelecterに生成します
297  */
298 function logXMLLoader(){
299     // レコード構造を定義します
300     var PathRecord = new Ext.data.Record.create([{
301         name: "display",
302         type: "string"
303     }, {
304         name: "path",
305         type: "string"
306     }]);
307     
308     // ログ用のXMLを読み込みます
309     var logXMLData = new Ext.data.Store();
310     jQuery.ajax({
311         url: logXmlUrl,
312         method: "GET",
313         error: showError,
314         success: function(xmlData){
315             var separateTag = xmlData.getElementsByTagName("file");
316             logData = new Array(separateTag.length);
317             
318             // 読み込んだ要素をStoreに格納して表示
319             for (var i = 0; i < separateTag.length; i++) {
320                 // "path"ノードの値を格納
321                 logData[i] = separateTag[i].getElementsByTagName("path")[0].firstChild.nodeValue;
322                 // レコードに登録する
323                 var record = new PathRecord({
324                     path: separateTag[i].getElementsByTagName("path")[0].firstChild.nodeValue,
325                     display: separateTag[i].getElementsByTagName("display")[0].firstChild.nodeValue
326                 });
327                 logXMLData.add(record);
328             }
329             
330             // コンボボックス要素を生成
331             document.getElementById("logSelecter").innerHTML = "<input type='text' id='logbox' style='width: " + extComboWidth + "px'/>";
332             
333             // コンボボックスを生成
334             comboBox = new Ext.form.ComboBox({
335                 store: logXMLData,
336                 applyTo: "logbox",
337                 displayField: "display",
338                 valueField: "path",
339                 mode: "local",
340                 triggerAction: "all",
341                 emptyText: "ログを選択してください..."
342             });
343         }
344     });
345 }
346
347 /**
348  * 検索単語を取得します
349  */
350 function getSearchWords(){
351     var searchWord = document.getElementById("searchWord").value;
352     if (searchWord == "") 
353         return null;
354     var searchWords = [];
355     
356     // 検索単語をサニタイジングします
357     // HTMLのメタ文字
358     searchWord = xmlAttrContentEscape(searchWord);
359     // 正規表現のメタ文字
360     searchWord = searchWord.replace(/([$()*+.?\[\\\]^{}])/g, '\\$1');
361     // 半角スペースで配列に分割
362     searchWords = searchWord.replace(/^\s+|\+$/g, '').split(/\s+/);
363     // 正規表現の選択を長い順に並び替えます(AND条件)
364     searchWords.sort(compareLengthDecrease);
365     
366     return searchWords.length == 0 ? null : searchWords;
367 }
368
369 /**
370  * 文章内を特定の単語で検索し、一致した部分を強調表示タグで置き換えます
371  * @param {String} searchWord 探索する単語
372  * @param {String} plainText 探索を行う文章
373  * @param {String} regexpType 正規表現の検索モードを示す文字列
374  */
375 function complexEmphasize(searchWord, plainText, regexpType){
376     // 正規表現の選択を長い順に並び替える
377     searchWord = searchWord.split('|').sort(compareLengthDecrease).join('|');
378     // タグの内側でないことを確認する正規表現を追加
379     var pattern = new RegExp('(?:' + searchWord + ')(?![^<>]*>)', regexpType);
380     
381     var result = [];
382     var currentIndex = -1; // 現在マッチしている部分文字列の開始位置
383     var currentLastIndex = -1; // 現在マッチしている部分文字列の、現在の末尾
384     var m; // 正規表現マッチの結果配列
385     while (m = pattern.exec(plainText)) {
386         if (m.index > currentLastIndex) {
387             // 新しい部分文字列へのマッチが始まったので、そこまでの文字列をバッファに書き出す
388             if (currentIndex < currentLastIndex) 
389                 result.push(emphasizeWord(plainText.substring(currentIndex, currentLastIndex)));
390             result.push(plainText.substring(currentLastIndex, m.index));
391             // 開始位置の更新
392             currentIndex = m.index;
393         }
394         // 末尾位置を更新
395         currentLastIndex = pattern.lastIndex;
396         // 次の正規表現マッチは今マッチした文字の次の文字から
397         pattern.lastIndex = m.index + 1;
398     }
399     // 残った文字列を書き出す
400     if (currentIndex < currentLastIndex) 
401         result.push(emphasizeWord(plainText.substring(currentIndex, currentLastIndex)));
402     result.push(plainText.substring(currentLastIndex));
403     
404     // 結合して返す
405     return result.join('');
406 }
407
408 /**
409  * 検索結果を分割して表示します(2回目以降呼び出し)
410  * @param {int} showLength 一回の画面に表示する記事数
411  * @param {int} startIndex 表示を開始する日記のインデックス
412  */
413 function showEntriesRange(showLength, startIndex){
414     // メモリ上から日記データをロード
415     var entries = loadedEntries;
416     
417     // 表示インデックスが範囲外の場合はエラーパネルを表示して終了
418     if (startIndex < 0 || entries.length <= startIndex) {
419         showError();
420         return;
421     }
422     
423     var stringBuffer = [];
424     
425     // リミッターを設定する
426     var loopLimit = (showLength + startIndex > entries.length) ? entries.length : showLength + startIndex;
427     var indexShowEntries = loopLimit + 1;
428     
429     for (var i = startIndex; i < loopLimit; i++) {
430         stringBuffer.push('<div id="drawPanel');
431         stringBuffer.push(i);
432         stringBuffer.push('"><div id="drawItem');
433         stringBuffer.push(i);
434         stringBuffer.push('" class="code"><\/div><\/div><div style="line-height: ' + entrySpan + 'px;"><br/></div>');
435     }
436     document.getElementById("writeArea").innerHTML = stringBuffer.join('');
437     
438     stringBuffer.length = 0;
439     for (i = startIndex; i < loopLimit; i++) {
440         var entry = entries[i];
441         document.getElementById("drawItem" + i).innerHTML = contentsWithid(entry.content, entry.id);
442         generatePanel(entry.title + " / " + entry.date, "drawItem" + i, "drawPanel" + i, false);
443     }
444     
445     // メニュー表示用バッファ
446     var menuBuffer = [];
447     menuBuffer.push("<table width='100%' class='pager'><tbody><tr>");
448     // 左パネルの表示制御
449     if (startIndex - showLength >= 0) {
450         menuBuffer.push("\<td align='left'><a href='' onclick='showEntriesRange(" +
451         showLength +
452         ", " +
453         (startIndex - showLength) +
454         "); return false;'>\<\<\< 前の" +
455         showLength +
456         "件を表示</a\></td>");
457     }
458     else {
459         menuBuffer.push("\<td align='left'>\<\<\< 前の" +
460         showLength +
461         "件を表示</a\></td>");
462     }
463     
464     // 中央のパネルの表示制御
465     menuBuffer.push("<td align='center'>[ ");
466     var menuNumbers = Math.ceil(entries.length / showLength);
467     for (i = 0; i < menuNumbers; i++) {
468         if (startIndex / showLength == i) {
469             menuBuffer.push(i + " ");
470         }
471         else {
472             menuBuffer.push("<a href='' onclick='showEntriesRange(" +
473             showLength +
474             ", " +
475             (i * showLength) +
476             "); return false;'>");
477             menuBuffer.push(i);
478             menuBuffer.push("</a> ");
479         }
480     }
481     menuBuffer.push("]</td>");
482     
483     // 右パネルの表示制御
484     if (entries.length > startIndex + showLength) {
485         menuBuffer.push("\<td align='right'><a href='' onclick='showEntriesRange(" +
486         showLength +
487         ", " +
488         (startIndex + showLength) +
489         "); return false;'>次の" +
490         showLength +
491         "件を表示 \>\>\></a\></td>");
492     }
493     else {
494         menuBuffer.push("\<td align='right'>次の" +
495         showLength +
496         "件を表示 \>\>\></a\></td>");
497     }
498     menuBuffer.push("</tr></tbody></table>");
499     
500     // 検索結果を表示します
501     document.getElementById("resultWriteArea").innerHTML = "\<b\>検索結果\</b\>\<br/\>" +
502     entries.length +
503     "件の記事が該当しました /  " +
504     (startIndex + 1) +
505     "~" +
506     loopLimit +
507     "件目までを表示中<br/>" +
508     menuBuffer.join("");
509     
510     document.getElementById("pagerAreaBottom").innerHTML = menuBuffer.join("");
511 }
512
513 /**
514  * 検索時のjQuery.ajaxのcallback関数
515  */
516 function fetchEntries(xmlData){
517     // 大文字小文字を区別するかを取得します
518     var regexpOptionI = document.searchForm.regexpOptionI.checked;
519     var regexpType = regexpOptionI ? "ig" : "g";
520     
521     // entry要素のみを切り出します
522     var entries = xmlData.getElementsByTagName("entry");
523     
524     // entry要素の回数だけ実行します
525     for (var j = 0; j < entries.length; j++) {
526         var entry = new Entry(entries[j]);
527         
528         // 正規表現が一致した場合は、強調表現処理を行います
529         if (entry.hasKeywords(currentSearchWords, regexpType)) {
530             // 強調表現を実行します
531             entry.title = complexEmphasize(currentSearchWords.join("|"), entry.title, regexpType);
532             entry.content = complexEmphasize(currentSearchWords.join("|"), entry.content, regexpType);
533             
534             fetchEntriesSemaphore.buf.push(entry);
535         }
536     }
537     
538     // セマフォのカウンタを減少させます (Ajaxとの同期のため)
539     fetchEntriesSemaphore.count--;
540     
541     // 全てのログを読み終わったら表示
542     if (fetchEntriesSemaphore.count == 0) {
543         var entries = fetchEntriesSemaphore.buf;
544         
545         // 一軒も検索にヒットしなかった場合は専用のパネルを表示して終了
546         if (entries.length == 0) {
547             notFoundError();
548             return;
549         }
550         
551         // entryをidでソート
552         entries = entries.sort(function(a, b){
553             a = a.id;
554             b = b.id;
555             return a > b ? -1 : a < b ? 1 : 0
556         });
557         
558         loadedEntries = entries;
559         
560         // 表示ロジック呼び出し
561         showEntriesRange(showLength, 0);
562     }
563 }
564
565 /**
566  * 「探索」ボタンを押されたときに呼び出されるメソッドです
567  */
568 function searchDiary(){
569     // 検索結果フィールドをクリアします
570     document.getElementById("writeArea").innerHTML = "";
571     
572     // 探索したい単語を取得します
573     currentSearchWords = getSearchWords();
574     if (!currentSearchWords) {
575         Ext.Msg.alert("ERROR", "検索対象の単語が入力されていません");
576         // 検索結果の欄をリセットします
577         document.getElementById("resultWriteArea").innerHTML = "\<b\>検索結果\</b\>";
578         return;
579     }
580     
581     // ロードエフェクトを表示します
582     loadingEffect();
583     
584     // 全チェックを取得します
585     var allCheckedFlag = document.searchForm.allSearchCheck.checked;
586     
587     // セマフォを初期化
588     fetchEntriesSemaphore.init();
589     // 日記が全検索モードか否かをチェックします
590     var isAsyncOn = null;
591     var urls = null;
592     if (allCheckedFlag == true) {
593         // 全文検索時、通信のモードが非同期か否か
594         isAsyncOn = document.searchForm.isAsyncOn.checked;
595         // 全日記検索なので全てのログのURL
596         urls = logData;
597     }
598     else {
599         // 単独日記探索なので、選んだログのURL
600         urls = [comboBox.getValue()];
601     }
602     fetchEntriesSemaphore.urls = urls;
603     fetchEntriesSemaphore.count = urls.length;
604     for (i = 0; i < urls.length; i++) {
605         var xhr = new jQuery.ajax({
606             url: urls[i],
607             method: "POST",
608             async: isAsyncOn,
609             success: fetchEntries,
610             error: showError
611         });
612         fetchEntriesSemaphore.xhrs.push(xhr);
613     }
614 }