Greasemonkeyによるアプリケーション開発

第3回Greasemonkeyによるカレンダアプリケーションの機能向上

前回からGreasemonkeyによるアプリケーション開発の題材としてカレンダアプリケーションを挙げ、キー押し下げにより表示・非表示を切り替えできるカレンダアプリケーションを作成しました。

今回はさらに二つの機能を追加したいと思います。

  • (1) 異なる月も表示する
  • (2) 予定情報を登録/表示できるようにする

異なる月の表示機能

異なる月に移動できるようにするため、カレンダ生成用の関数に手を加えます。

「現在の月」を表す変数を用意して、その値が示す月に対応するカレンダ生成を行うように変更すればうまくいきそうです。異なる月に移動するときは、その月の値を増減した上でカレンダ生成用関数を呼び出せばよいわけです。

カレンダに追加する機能の検討

早速その実装をはじめようと思うところですが、その前に少しだけ先のステップのことも考えてみます。最終的には予定情報の管理機能も追加したいので、その操作方法も先に考えておきます。

まず、登録する予定情報として記録するデータは、以下のものとします。

  • 予定の年月日
  • 予定の内容

予定情報の表示については、カレンダの大きさをコンパクトなままにしておきたいので、以下の点を考慮します。

  • 予定情報が登録されている日付は色を変えて表示する
  • 日付をカーソルキーなどで選択できるようにして、選択日に予定情報が登録されていればその内容を表示する

また、予定情報を登録するときも選択されている日付を予定情報の年月日として使うようにします。

ということで、日付を選択できるようにしたので、単純に表示中のカレンダの月を動かすだけでなく、

  • 「選択日」を動かすようにする
  • 「選択日」の月に応じたカレンダを表示する

という処理を実装することとします。

選択日の移動機能の実装

図1図2(次ページ)に上記処理を実装したユーザスクリプトを示します。図1は前回のカレンダ生成処理から変更した部分を中心に示し、図2は今回のスクリプトで新たに実装した「選択日」を動かす部分を示しています。以下、順に解説します。

図1 選択日の移動処理を実装したユーザスクリプト前半部分(ユーザスクリプトのソースコード全体はこちら
// ==UserScript==
// @name         mini_calendar5
// @namespace    http://gomaxfire.dnsdojo.com/
// @description  the 5th mini calendar
// @include      *
// ==/UserScript==

/* ユーティリティ関数郡の定義 */
/* 中略 */

/** document.getElementById() のエイリアス */
function $(id){
  return document.getElementById(id);
}

/** 
 * Element#removeChild()のエイリアス
 * $rmに与えた要素を削除する
 */

function $rm(element){
  if(element && element.parentNode)element.parentNode.removeChild(element);
}

//----------------------------------------------------
// calendar application
//----------------------------------------------------

var calendar = (function(){                // -(1)
    var frame = $div();                    // -(2)
    var table = null;
    // calendar用処理群の中で共通利用する変数群を定義
    var curDate = new Date(); //選択中の日付 -(3)
    var TODAY = new Date();
    var TODAY_YEAR = TODAY.getFullYear();
    var TODAY_MONTH = TODAY.getMonth();
    var TODAY_DATE = TODAY.getDate();
    var CSS_PREFIX = "_gcal_";
    
    function css(name){
      return CSS_PREFIX + name;
    }

    // 選択中の日付セルを処理しやすくするために
    // 日付セルにIDを付加する。そのIDをつくる関数  -(4)
    function makeDateId(d){
      return css([d.getFullYear(), 
                  f(d.getMonth() + 1), 
                  f(d.getDate())].join("-"));
      function f(n){
        return n < 10 ? "0" + n : n;
      }
    }

    // calendar処理群としてまとめたので
    // makeCalendarTableからmakeTableに名称変更
    function makeTable(){
      var DAYS = "Sunday Monday Tuesday Wednesday Thursday Friday Saturday".split(" ");
      $rm($(css("")));// 別の月に変更する場合のために以前の内容を破棄 -(2)
      table =  $table({id:css("")});       //-(2)
      $add(frame, table);                  //-(2)
      setMonthHeader();
      setDayHeader();
      setDates();
      setStyle();
      return frame;
  
      function setMonthHeader(){
        // curDateの年/月を使う
        var year = curDate.getFullYear();   // -(3)
        var month = curDate.getMonth() + 1; // -(3)
        $add(table, 
             $add($tr({className:css("header")}),  
                  $add($th({className:css("header"), colSpan:7}), year + "/" + month))); 
      }
      // 中略
  
      function setDates(){
        // curDate自体を動かさないように、
        // curDateを複製して表示用Dateオブジェクトとして利用する 
        var d = new Date(curDate);          // -(3)
        var nextMonth = d.getMonth() < 11 ? d.getMonth() + 1 : 0;
        d.setDate(1);
        d.setDate(-d.getDay() + 1);
        // 中略    

        /**
         * 年月日が一致したときのみ「今日」の見栄えにする
         */ 
        function setClassName(td, d){
          var buffer = [];
          if(d.getMonth() == curDate.getMonth()){ // -(3)
            buffer.push(css("onDate"));
            buffer.push(css(DAYS[d.getDay()]));
          } else {
            buffer.push(css("outDate"));
          }
          if(d.getDate() == TODAY_DATE &&
             d.getMonth() == TODAY_MONTH &&
             d.getFullYear() == TODAY_YEAR){
            buffer.push(css("today"));
          }
          td.className = buffer.join(" ");
        }
        
      }

      function setStyle(){
        var style =
        <><![CDATA[
         //中略
                   #_gcal_ td._gcal_select { //-(5)
                     color : gold;
                     font-weight : bold;
                     border-top : 1px solid white;
                     border-left : 1px solid white;
                     border-bottom : 1px solid #666666;
                     border-right : 1px solid #666666;
                     background-color:#FFFFEE;
                   }
                   ]]></>;
        GM_addStyle(style);
      }
    }

    var gPanel = null; 
    function toggleCalendar(){
      if(!gPanel) {
        gPanel = $add($div({id:"_gpanel"}), makeTable());
        $add(document.body, gPanel);
        selectDate(curDate);
      }
      with(gPanel.style){
        if(display != "block"){
          display = "block";
        } else {
          display = "none";
        }
      }
    }

    /* 図2のA部分がここにくる */
    
    // 外部インタフェースとなるオブジェクトを返す // -(1)
    return {toggle:toggleCalendar,
            nextMonth:makeGoMonthOffset(1),
            previousMonth:makeGoMonthOffset(-1),
            goToday:goToday,
            nextDate:makeGoDateOffset(1),
            previousDate:makeGoDateOffset(-1),
            nextWeek:makeGoDateOffset(7),
            previousWeek:makeGoDateOffset(-7)
    };
  })();

    /* 図2のB部分がここにくる */

前回からの変更点はいくつかありますが、ポイントとなる部分にコメントで番号づけしました。以下その番号順に解説します。

(1)カレンダ処理をオブジェクトにまとめた

これまでのユーザスクリプトではカレンダ処理は「実行時の月のカレンダを表示する」という処理一つだけだったのですが、今回からは選択日の移動処理や月の移動処理など複数の処理を定義する必要が出てきました。それら複数の処理を定義するために、複数の関数を定義するわけですが、それらの関数は次の二つの種類に分けられます。

  • (a) カレンダ処理を利用するために呼び出す関数。
  • (b) 関数の定義のために定義した関数。カレンダ処理のために直接呼び出すことはない。

この二種類の関数定義がそのまま混在していると混乱の元ですし、間違って(b)のタイプの関数を呼び出してしまうと意図せず(a)の処理に影響してしまう可能性もあります。そのため、⁠a)のタイプの関数だけを外部から呼び出せるようにし、⁠b)のタイプの関数を内部に閉じ込めて外部から呼び出すことができないようにするため、オブジェクトの形にまとめるようにしました。

(2)月の変更に応じたカレンダテーブルの削除処理のため、それを囲むdiv要素を加えた

月を変更したときはカレンダを表すテーブルを一旦削除し、改めて変更した月に応じたカレンダテーブルを生成するように処理を変更しました。そのため、再生成したカレンダテーブルを配置する「場所」が必要になり、その役目をするdiv要素を置くことにしました。

(3)選択日に応じてカレンダを生成するようにした

これまではnew Date()によって得られる実行時の日付(の月)に応じたカレンダを生成していましたが、⁠選択日」を変数curDateで表すこととして、その日付に応じてカレンダを生成するようにしました。

(4)日付セルを処理しやすくするため、IDを付加するようにした

「選択日」がどれなのか、表示上分かりやすくするために選択日を示す日付セルのclass属性の値を変えて見栄えを変えるようにしました。この日付セルの見栄えを変更する処理のため、見栄えを変更すべき日付セルを特定する必要が必要になるわけですが、この日付セルの特定を簡単に行えるようにするため各日付セルにID属性を付加することにしました。今回追加したユーティリティ関数「$」を使うことで、IDを指定すれば簡単に所望の日付セル要素を取得できるからです。

(5)選択日のスタイル定義を追加した

カレンダテーブル中の日付セルのうちどれが「選択日」なのか一目で分かるよう、スタイル定義を追加しました。

以上、前回のカレンダ生成処理からの主要な変更点を解説しました。

次に、今回のスクリプトで新たに実装した「選択日」を動かす部分について解説します。

図2 選択日の移動処理を実装したユーザスクリプト後半部分(ユーザスクリプトのソースコード全体はこちら
    // ↓↓↓↓ここからA部分 ↓↓↓↓
    /**
     * dateで選択した日付に移動する
     */
    function selectDate(date){             // -(1)
      changeMonthIfNeed(date);             // -(1-a)
      unselect(curDate);                   // -(1-b)
      select(date);                        // -(1-c)
      
      function changeMonthIfNeed(date){
        if(curDate.getMonth() != date.getMonth()){
          curDate.setDate(1); // 意図せず二月分移動してしまうのを防ぐため
          curDate.setMonth(date.getMonth());
          curDate.setFullYear(date.getFullYear());
          makeTable();
        }
      }
      function unselect(date){
        var preSelect = $(makeDateId(date));
        if(preSelect){
          preSelect.className = 
            preSelect.className.replace(/ _gcal_select/, "");
        }
      }
      function select(date){
        curDate = new Date(date);
        var select = $(makeDateId(date));
        select.className  += " _gcal_select";
      }
    }
    
    /**
     * offset分だけ月を変更してカレンダテーブルの再生成をする
     * 選択日は1日にする
     * <外部インタフェース生成用>
     */
    function makeGoMonthOffset(offset){    // -(2)
      return function(){
        if(!isShown())return;
        var newDate = new Date(curDate);
        newDate.setDate(1);
        newDate.setMonth(curDate.getMonth() + offset);
        selectDate(newDate);
      };
    }

    /**
     * offset分だけ日付を変更してカレンダテーブルの再生成をする
     * <外部インタフェース生成用>
     */
    function makeGoDateOffset(offset){     //-(3)
      return function (){
        if(!isShown())return;
        var newDate = new Date(curDate);
        newDate.setDate(newDate.getDate() + offset);
        selectDate(newDate);
      };
    }

    /**
     * 実行時の日付に移動する
     * <外部インタフェース用>
     */
    function goToday(){                    //-(4)
      if(isShown())selectDate(TODAY);
    }

    /**
     * 選択日の移動処理はカレンダを表示しているときのみ
     * 実行したい。そのために表示中か否かを示す関数を用意。
     */
    function isShown(){
      return gPanel && gPanel.style.display != "none";
    }
    // ↑↑↑↑ここまでA部分 ↑↑↑↑
    
    // 外部インタフェースとなるオブジェクトを返す(再掲)
    return {toggle:toggleCalendar,
            nextMonth:makeGoMonthOffset(1),      //(2-a)
            previousMonth:makeGoMonthOffset(-1), //(2-b)
            goToday:goToday,
            nextDate:makeGoDateOffset(1),        //(3-a)
            previousDate:makeGoDateOffset(-1),   //(3-b)
            nextWeek:makeGoDateOffset(7),        //(3-c)
            previousWeek:makeGoDateOffset(-7)    //(3-d)
    };
  })();

// ↓↓↓↓ここからB部分 ↓↓↓↓
keybind("S-c", calendar.toggle); 
keybind("S-n S-right S-down".split(" "), calendar.nextMonth); 
keybind("S-p S-left S-up".split(" "), calendar.previousMonth); 
keybind("f right".split(" "), calendar.nextDate); 
keybind("b left".split(" "), calendar.previousDate); 
keybind("n down".split(" "), calendar.nextWeek); 
keybind("p up".split(" "), calendar.previousWeek); 
keybind("S-t", calendar.goToday); 
// ↑↑↑↑ここまでB部分 ↑↑↑↑

図2のA部分は選択日を移動する処理用の関数の定義で、B部分はキーバインドの定義です。A部分で定義している主な関数は(1)selectDate、(2)makeGoMonthOffset、(3)makeGoDateOffset、(4)goToday の四つです。以下、各関数について解説します。

(1)selectDate:引数で指定した日付を「選択日」とする

この関数では(a)必要に応じて月を変更してカレンダテーブルを再生成し、(b)現在選択中の日付を非選択にし、(c)指定された日付を選択する、という処理をしています。

(a)の処理は選択中の月と指定された月が異なる場合に実行するようにしています。

選択日curDateの日にちを1日に設定してから指定されたdateの月、年に設定し、カレンダテーブルを再生成しています。最初に日にちを1日に設定しているのは、日にちによっては意図した月と異なる月になってしまう可能性があるためです。たとえば1月31日を表すDateオブジェクトに対しsetMonth(1)を実行する(Dateオブジェクトは1月を0として扱うため、1は2月を表す)と、2月31日ではなく、3月3日に変更されます。このようにDateオブジェクトは存在しない日時を示さないように適切に処理する機能があるのですが、カレンダの生成処理では指定した月に応じたカレンダを表示したいので、このような月のジャンプが起こらないようにしたいわけです。そのため日にちを先に1日するようにしています。

(b)の処理は選択日から日付セル要素を特定し、その日付セル要素のクラス名から選択日の見栄え用のクラス名を除去しています。

(c)の処理は指定された日付を新たに選択日に設定し、該当する日付セル要素のクラス名に選択日の見栄え用のクラス名を付加しています。

なお、前回も解説しましたが、ここでも関数定義を分割することで、コメントなしでもコードが読みやすくなるように工夫しています。

私は一つの関数の定義が長くなり、数十行を越えてくると全体として何をやろうとしているのか自分でも分からなくなってしまうので、このようにできるだけ関数定義は処理の単位で分割するようにしています。

ありがたいことにJavaScriptでは関数の内部で関数を定義できるので、その関数の外部で使う必要のない(内部の)関数は内部で定義しておくことができ、長い定義になるような関数の分割がやりやすくなっています。

ただし、デメリットもあって、関数呼び出しが増えるとその分実行速度も低下します。実行速度を重視したい場合は分割の度合いを下げる必要があるかもしれません。

(2)makeGoMonthOffset:引数で指定された分だけ月を移動する関数を生成する

本来必要なのは先月、来月に移動するための関数なのですが、その関数を生成するための関数を用意することでそれぞれの関数を定義する手間を省いています。引数に与えた数の分だけ現在の日付の月から移動する関数を生成するようにしているので、先月への移動は-1、来月への移動は1を引数として与えてそれぞれの関数を生成しています(2-a、2-b⁠⁠。

また今回の実装では先月、来月に移動する処理だけを使っていますが、前年、来年に移動する処理の場合でも、±12を引数に与えれば同様の関数を生成することができます。

(3)makeGoDateOffset:引数で指定された分だけ選択日を移動する関数を生成する

makeGoMonthOffsetの日にち移動用版です。前の日、次の日、一週間前、一週間後に移動するための関数を生成するために定義しました(3-a、3-b、3-c、3-d⁠⁠。

(4)goToday : 実行時の日付に移動する

月を何度も移動しているとふと今日に戻りたくなるものです。そのための関数を用意しておきました。

以上が今回のユーザスクリプトの主要な部分でした。

最後のB部分は前回導入したkeybind関数を使って選択日の移動のためのキー操作を定義しています。⁠読んで字のごとく」な定義になっていると思いますので解説の必要はないでしょう。

今回の実装により、カーソルキー(とf、b、p、nのEmacsライクなキー操作)を使って選択日を上下左右に移動させたり、シフト+カーソルキー(とf、b、p、nのEmacsライクなキー操作)を使って月移動させたりできるようになりました。

カレンダアプリケーションとしての便利度をちょっとあげることができたと思います。

予定情報の登録/表示機能

次は予定情報を登録/表示できるようにします。以下のような仕様にします。

(1)登録済み予定情報の表示
  • 予定情報が登録されている日付のセルは色を変えて表示する
  • 選択日に予定情報が登録されていればその情報を表示する
(1)登録済み予定情報の表示
(2)予定情報の登録
  • カレンダ画面下に追加用ボタンを配置する
  • 追加用ボタンを押すと予定情報登録フォームが開く
  • 予定情報登録フォームに情報を入力することで予定追加できる
(2)予定情報の登録
(3)登録済み予定情報の編集
  • 予定情報の表示欄に編集用ボタンをつける
  • 編集用ボタンを押すと編集用フォームが開く
  • 編集用フォームは登録済みの予定情報が入力済みになる
  • 編集用フォームに更新情報を入力しすると更新できるようにする。
(3)登録済み予定情報の編集
(4)登録済み予定情報の削除
  • 予定情報の表示欄に削除用ボタンをつける
  • 削除用ボタンを押すと予定情報が削除できる
  • 削除の前に確認ダイアログを表示する(キャンセルなら削除しない)

仕様として書き下すとたった四つの機能なのですが、これまでのように単純にカレンダを表示していただけのものと比べると実装すべき処理が増えており、コード量も一気にアップします(約倍増⁠⁠。

そのため、これ以降はポイントとなる部分にしぼって解説することにします。特段複雑な書き方をした部分はありませんので、詳細についてはソースコードを読んでいただければ比較的容易にご理解いただけると思います。

予定情報管理機能の実装におけるポイント

今回のポイントは以下の四つです。

(1)予定情報管理機能はカレンダオブジェクトとは別にスケジューラオブジェクトにまとめた

一ページ目の「異なる月の表示機能」のユーザスクリプトでカレンダの表示に関わる処理をカレンダオブジェクトにまとめることにしましたので、予定情報管理に関わる処理はそれとは別のスケジューラオブジェクトにまとめるようにしました。

(2)予定情報の永続化にGM_setValue、GM_getValueを利用した

Greasemonkeyにはいくつか組み込みの関数が定義されていますが、そのうちのGM_setValue、GM_getValueは文字列データの永続化に利用できる関数で、予定情報の管理にもこの二つの関数を使いました。

(3)画像データはdataスキームを使ってスクリプト内に埋め込んだ

予定情報の追加ボタン、編集ボタン、削除ボタン用の画像データはdataスキームを使ってスクリプト内に埋め込むようにしました。こうすることでオフラインでも画像データを表示できるようになります。

(4)オブジェクト間の連携のためにイベント通知処理用オブジェクトを作成した

ポイント(1)に付随することなのですが、カレンダ表示と予定情報管理の機能をそれぞれ別のオブジェクトとして定義したので、そのオブジェクト間で連携する処理が必要となります。これのオブジェクト間の連携処理に、ユーザ操作に対するイベント処理と同様のオブザーバパターンを利用することとし、そのためのイベント通知処理用オブジェクトを作成し、これを利用することにしました。

これ以降、ユーザスクリプトのソースコードを示しながら以上のポイントを中心に解説します。

カレンダオブジェクトの変更部分

図3はカレンダオブジェクトの変更部分を示しています。

図3 カレンダオブジェクトの変更部分(ユーザスクリプトのソースコード全体はこちら
// ==UserScript==
// @name         mini_calendar6
// @namespace    http://gomaxfire.dnsdojo.com/
// @description  the 6th mini calendar
// @include      *
// ==/UserScript==

/* ユーティリティ関数をここで定義するが省略 */

/**
 * イベント通知処理用オブジェクト       -(1)
 */
function Observer(){
  this.init.apply(this, arguments);
}

Observer.prototype = {
  init:function(){
    this.listeners = [];
  },
  addListener:function(func){
    this.listeners.push(func);
  },
  notify:function(){
    var args = arguments;
    this.listeners.forEach(function(func){
        var result = func.apply(null, args);
      });
  }
};

/**
 * cssのIDやclass名のprefix付加処理
 * calendarとschedulerで利用するためそれらの外側に移動  -(2)
 */
var PREFIX = "_gcal_";
function css(name){
  return PREFIX + name;
}

/**
 * カレンダオブジェクトの定義
 */
var calendar = (function(){
    // 中略
    /**
     * カレンダの日付セルにクラス名を追加するObserver    -(3-1)
     * <外部インタフェース用>
     */
    var classNameObserver = new Observer(); 
    function addSetClassNameListener(func){                         
      classNameObserver.addListener(makeFunc(func));
      function makeFunc(func){                // -(3-2)  
        return function(date, td){
          var className = func(date);
          if(className && td.className.indexOf(className) < 0){
            td.className += " " + className;
          }
        };
      }
    }

    /**
     * 日付選択時の追加処理用Observer            // -(3-4)
     * <外部インタフェース用>
     */
    var selectDateObserver = new Observer();
    function addSelectDateListener(func){
      selectDateObserver.addListener(func);
    }

    /**
     * dateによって指定された日付セルにクラス名を追加する関数を生成する
     *  -(4-1) <外部インタフェース用>
     */
    function makeAddClassNameToDateCell(className){
      return function(date){
        var cell = $(makeDateId(date));
        if(cell && cell.className.indexOf(className)<0){
          cell.className += " " + className;
        }
        selectDate(date);
      };
    }

    
    /**
     * dateによって指定された日付セルからクラス名を削除する関数を生成する
     *  -(4-2) <外部インタフェース用>
     */
    function makeDeleteClassNameFromDateCell(className){
      var regexp = new RegExp(" " + className);
      return function(date){
        var cell = $(makeDateId(date));
        if(cell) cell.className = cell.className.replace(regexp, "");
      };
    }
    
    /**
     * 日付セル用のIDを生成する
     */
    function makeDateId(d){
      return css([d.getFullYear(), 
                  f(d.getMonth() + 1), 
                  f(d.getDate())].join("-"));
      function f(n){
        return n < 10 ? "0" + n : n;
      }
    }

    // 中略

    function makeTable(){
      // 中略

        // 日付セルの見栄えを設定するために
        // クラス名を付加するリスナ関数の呼び出しも最後に行う
        function setClassName(td, d){
          // 中略
          classNameObserver.notify(d, td);      // -(3-3)
        }
      }
    }

    /**
     * dateで選択した日付に移動する
     */
    function selectDate(date){
      changeMonthIfNeed(date);
      unselect(curDate);
      select(date);

      // 中略
      function select(date){
        curDate = new Date(date);
        var select = $(makeDateId(date));
        select.className  += " _gcal_select";
        selectDateObserver.notify(date);        // -(3-5)
      }
    }
    
    // 中略
    
    // 外部インタフェース用関数をオブジェクトにまとめて返す
    return {nextMonth:makeGoMonthOffset(1),
            // 中略
            addSetClassNameListener:addSetClassNameListener,
            makeAddClassNameToDateCell:makeAddClassNameToDateCell,
            makeDeleteClassNameFromDateCell:makeDeleteClassNameFromDateCell,
            addSelectDateListener:addSelectDateListener
    };
  })();

変更点のポイントは以下の四点です。

(1)イベント処理用にObserverオブジェクトを定義した

Observerはオブジェクト間の連係動作用にObserverパターンを使うために定義したオブジェクトです。特定のイベントが発生したときに、そのイベントを処理する関数(リスナ関数)を呼び出す役目を持ちます。リスナ関数は前もってObserverオブジェクトに登録しておきます。

Observerオブジェクトは以下の関数を持ちます。

  • addListener:リスナ関数を登録する
  • notify:登録済みのリスナ関数を呼び出す

notify関数はイベントが発生した時点で呼び出すようにします。notify関数の引数をリスナ関数の呼び出し時にも与えるようにしています。リスナ関数の形式(引数の渡し方、返す値)は、Observerごとに固定の形式に決める必要がありますが、その形式はObserverを使う場所に応じて自由に決めればよいわけです。

このObserverオブジェクトを導入することで、オブジェクト内に連携対象のオブジェクトを呼び出すコードを直接書く必要がなくなり、メンテナンスしやすくなります。また、さらに別のオブジェクトを連携対象にする場合もaddListener関数を使って追加登録するだけですむ、というメリットもあります。

(2)他のオブジェクトでも利用する関数をオブジェクトの外部で定義した

関数cssは両者で共有して利用できるようにcalendarオブジェクトの外側で定義するようにしました。

(3)見栄えの変更処理用に、リスナ関数の登録用関数を外部インタフェース用関数として定義した

(1)のObserverオブジェクトを使って、以下の二つのリスナ関数登録用の外部インタフェース用関数を定義しました。

addSetClassNameListener

カレンダテーブルの生成の際に、各日付セルを表すTD要素にクラス名を生成する処理をsetClassName関数で定義しています。ここで、外部オブジェクトからも日付によって見栄えを変えることができるように、クラス名を追加する処理を実行できるようにするため、Obserberを使いました(3-1⁠⁠。今回は「予定情報が登録されている日付のセルは色を変えて表示する」という仕様を実現するために使っています。リスナ関数はdateを引数とし、追加したいクラス名を返す形式のものを登録するようにしました。リスナ関数の呼び出し結果のクラス名を日付セルに設定できるように、リスナ関数に処理を追加した上でObserverに追加するようにしています(3-2⁠⁠。そのため、notify関数を呼び出すときは日付セルも引数として渡すようにしています(3-3⁠⁠。

addSelectDateListener

日付を選択したとき(選択日を移動したときに)に、何らかの処理を外部オブジェクトに実行させる仕組みを追加するためObserverを使いました(3-4⁠⁠。日付選択の処理を定義しているselectDate関数の処理の最後で通知処理(notify関数の呼び出し)を実行しています(3-5⁠⁠。今回は「選択日に予定情報が登録されていればその情報を表示する」という仕様を実現するために使っています。リスナ関数はdateを引数とするものものを登録するようにしました。

(4)外部から日付セルの見栄え変更を可能にする関数を外部インタフェース用関数として定義した

予定情報を追加、削除したタイミングでも日付セルの見栄えを変更できるようにするため、日付セルにクラス名を付加する関数の生成関数makeAddClassNameToDateCellと、クラス名を除去する関数の生成関数makeDeleteClassNameFromDateCellを定義しました。

両関数ともにクラス名を引数にとります。返す関数は、日付オブジェクトを引数にとる関数で、makeAddClassnameToDateCell関数は生成時に渡したクラス名を該当する日付セルに追加し、makeDeleteClassNameFromDateCell関数は生成時に渡したクラス名を該当する日付セルから除去します。

スケジューラオブジェクトの定義

図4はスケジューラオブジェクトの定義部分を示しています。

図4 スケジューラオブジェクトの定義 ⁠ユーザスクリプトのソースコード全体はこちら
var scheduler = (function(){
    var events = load(); 
    // 予定情報全体 日付をkeyにしたハッシュ。   -(1-1)
    // 値は予定情報のIDをkeyにしたハッシュ。
    var addEventObserver = new Observer(); // 予定情報を追加したときの付加処理のため
    var deleteEventObserver = new Observer(); // 予定情報を削除したときの付加処理のため

    function addAddEventListener(func){        // -(2-1)
      addEventObserver.addListener(func);
    }

    function addDeleteEventListener(func){     // -(2-2)
      deleteEventObserver.addListener(func);
    }


    //アイコンの画像バイナリデータ(base64エンコーディングしたもの)-(4)
    var ADD_ICON = "data:image/png;base64," +
      // 中略 (バイナリデータのbase64文字列が並ぶ) 
    var DELETE_ICON = "data:image/png,base64," + 
      // 中略 (バイナリデータのbase64文字列が並ぶ) 
    var EDIT_ICON = "data:image/png,base64," + 
      // 中略 (バイナリデータのbase64文字列が並ぶ) 


    function makeController(){
      var cntr = $div({id:css("sche_")});
      // 中略 (予定情報表示欄や予定情報登録用フォームのDOMツリーを生成)
    }
    
    function selectDate(date){
      $(css("sche_year")).value = date.getFullYear();
      $(css("sche_month")).value = date.getMonth() + 1;
      $(css("sche_date")).value = date.getDate();
      show(date);
    }

    function show(date){
      var events = getEvents(date);
      $rm($(css("sche_events")));
      var eventsTable = $table({id:css("sche_events"), 
                                cellSpacing:1, 
                                cellPadding:0});
      $add($(css("sche_events_frame")), eventsTable);
      for(id in events){
        event = events[id];
        if(!eventsTable.firstChild){
          $add(eventsTable,
               $add($tr(),
                    $add($td(),"events:")));
        }
        var deleteButton = makeDeleteButton(event);
        var editButton = makeEditButton(event);
        var eventDate = [event.year, event.month, event.date].join("/");
        var eventDescription = event.description ? event.description :"";
        $add(eventsTable,
             $add($tr(),
                  $add($td(), 
                       $add($p(),eventDate),
                       $add($p(),eventDescription),
                       deleteButton, editButton)
                  )
             );
      }

      // 中略 (makeEditButton、makeDeleteButtonの定義)
    }

    function getEventDate(event){
      return new Date(event.year, event.month -1 , event.date);
    }

    function addEvent(event){
      var index = eventIndex(event);
      if(!index)return;
      var list = events[index] || (events[index] = {});
      if(!event.id)event.id = makeEventId();
      list[event.id] = event;
      save();
      addEventObserver.notify(getEventDate(event));
      
      // 中略(makeEventIdの定義)
    }

    function deleteEvent(event){
      var index = eventIndex(event);
      if(!index)return;
      var list = events[index];
      if(!list)return;
      delete list[event.id];
      if(!existsEvent(list)){
        delete events[index];
        deleteEventObserver.notify(getEventDate(event));
      }
      save();
      show(getEventDate(event));

      // 中略(existsEventの定義)
    }
   
    function getEvents(date){                           
      return events[eventIndexByDate(date)] || {};
    }
    
    function hasEvents(date){              // -(3)
      return (eventIndexByDate(date) in events);
    }
    
    // 中略(共通利用関数の定義)
    
    function save(){                       // -(1-2)
      GM_setValue("events", events.toSource());
    }
    
    function load(){                       // -(1-3)
      return eval(GM_getValue("events", "({})")) || {};
    }

    // scheduler object
    return {selectDate:selectDate,
            makeController:makeController,
            addAddEventListener:addAddEventListener, // -(2-1)
            addDeleteEventListener:addDeleteEventListener, // -(2-2)
            hasEvents:hasEvents                     // -(3)
    };
  })();

スケジューラオブジェクトの定義におけるポイントは以下の4点です。

(1)予定情報はGM_setValue/GM_getValue関数を使って登録/参照するようにした

Greasemonkeyではデータの永続化のための関数GM_setValue、GM_getValueを利用することができます。

GM_setValueはデータの登録処理を行うためのもので、引数を二つとり、一つ目の引数はデータの名前、二つ目の引数はデータの値をとります。

GM_getValueはデータの取得処理を行うためのもので、引数を二つとり、一つ目の引数はデータの名前、二つ目の引数はデータが登録されていなかった場合に返すデフォルトの値(文字列)をとります。一つ目の引数で指定した名前のデータが登録されていればその値を返します。

予定情報は(1-1)で示すように変数eventsで管理するようにしました。二重のハッシュ構造にし、第一階層は日付を元にしたキーをとり、第二階層は個々の予定情報を特定するIDをキーにとるようにし、そこに予定情報を表すオブジェクトを登録するようにしました。今回のカレンダアプリケーションでは日付をひとつひとつ調べ、その日付に対応する予定情報があれば表示処理を行う、という処理を頻繁に実行するため日付の指定によって予定情報を簡単に取得できるようにしたかったため、このような構造にしました。

この変数eventsの値をGM_setValueで永続化すればGM_getValueで再度予定情報を変数eventsに戻すことができるわけですが、残念ながら永続化できるのは文字列データだけです。そのため、オブジェクトを文字列データに変換する必要があるわけですが、これにはJSON形式の文字列を使います。幸いなことにFirefoxではtoSource関数を呼び出すだけでオブジェクトのJSON文字列表現を生成できるので、これをそのまま永続化用の文字列生成処理に使いました(1-2⁠⁠。逆にJSON文字列をオブジェクトに戻すにはevalを使いました(1-3⁠⁠。

(2)予定情報の追加・削除後の処理のためのリスナ関数登録用関数を外部インタフェース用関数として定義した

外部オブジェクトに対し予定情報を追加、削除したことを通知するための手段として外部インタフェース用関数addAddEventListener、addDeleteEventListenerをObserverを使って定義しました(2-1、2-2⁠⁠。

四ページ目の「カレンダオブジェクトの変更部分」のポイント(4)の、カレンダオブジェクトの日付セルの見栄え変更用関数(を使って生成した関数)を、予定情報の追加、削除イベントのリスナ関数として登録することで、予定情報を追加、削除したタイミングで見栄えを変える機能を実現しています。

(3)予定情報の有無を返す関数を外部インターフェース用関数として定義した

四ページ目の「カレンダオブジェクトの変更部分」のポイント(3)にあげたとおり、カレンダテーブル生成時に日付セルにクラス名を追加するための処理を行うためのインタフェースをカレンダオブジェクトに用意しました。そのインタフェースによって、この予定情報の有無を返す関数を使ったリスナ関数を登録することで、予定情報の有無により日付セルの見栄えを変える処理を実現しています(リスナ関数の生成および登録処理は図5に示しました。解説は次ページで行っています⁠⁠。

(4)ボタン用画像はdataスキームを使って埋め込んだ

dataスキームはRFC2397で規定されているURLのスキームで、バイナリデータを文字列として表現することでHTML(やJavaScript)内に直接埋め込んだ形で記述することを可能にするものです。これを用いてボタン用画像を埋め込むようにしています。このようにdataスキームを使って画像データをユーザスクリプト内に埋め込むことでオフラインでもボタン用画像を表示することを可能にしています。

なお、ボタン用画像は famfamfam.comのSilk Iconsを、画像データのbase64化処理には[JavaScript]dataスキームURI生成(画像データのBase64変換)を、それぞれ利用させていただきました。

カレンダオブジェクトとスケジューラオブジェクトの連結

図5はカレンダオブジェクトとスケジューラオブジェクトの連結処理部分を示しています。

図5 カレンダオブジェクトとスケジューラオブジェクトの連結処理(ユーザスクリプトのソースコード全体はこちら
/**
 * カレンダの表示/非表示処理
 * calendarとschedulerを使う処理なので
 * その二つから切り離した
 */
var gPanel = null;
function toggleCalendar(){
  setPanelIfNeed(); 
  with(gPanel.style){
    if(display != "block"){
      display = "block";
    } else {
      display = "none";
    }
  }
  
  function setPanelIfNeed(){
    if(gPanel) return; // gPanelがあれば設定済み
    connectCalendarAndScheduler();
    gPanel = 
      $add($table({id:"_gpanel", 
                   cellSpacing:0,
                   cellPadding:1}), 
        $add($tr(),
             $add($td({id:css("frame")}), calendar.makeTable())),
        $add($tr(),
             $add($td({id:css("sche_frame")}), scheduler.makeController())));
    
    $add(document.body, gPanel);
    setStyle();
    calendar.goToday();
    
    function connectCalendarAndScheduler(){
      var HAS_EVENTS_CLASS_NAME = css("has_events");
      calendar.addSetClassNameListener(function(date){return scheduler.hasEvents(date) ? HAS_EVENTS_CLASS_NAME:"";});
      // -(1)
      calendar.addSelectDateListener(scheduler.selectDate);
      // -(2)
      scheduler.addAddEventListener(calendar.makeAddClassNameToDateCell(HAS_EVENTS_CLASS_NAME));
      // -(3)
      scheduler.addDeleteEventListener(calendar.makeDeleteClassNameFromDateCell(HAS_EVENTS_CLASS_NAME));
      // -(4)
    }
  }

  function setStyle(){
    var style =
      <><![CDATA[
                //中略
                 #_gcal_sche_frame{
                       background-color:#C3D9FF;
                       padding:2px;
                 }
                //中略
                 ]]></>;
    GM_addStyle(style);
  }
}

// 最後にキーバインド定義をするが省略

カレンダテーブルの表示/非表示処理用の関数toggleCalendarはこれまでカレンダオブジェクトの外部インタフェース用関数でした。

しかし、今回からはカレンダオブジェクトによるカレンダテーブルと、スケジューラオブジェクトによる予定情報の登録/表示用フォームとを組み合わせた「カレンダアプリケーション画面」⁠ソースコード中では変数gPanelで表現)が表示/非表示処理の対象となること、及びカレンダオブジェクトとスケジューラオブジェクトの処理を組み合わせる処理が必要になることから各オブジェクトから独立した関数として定義しました。

表示/非表示の処理、およびスタイルシートの定義処理はこれまでのものと変化はありません。ポイントは関数connectCalendarAndSchedulerで定義しているカレンダオブジェクトとスケジューラオブジェクトの連結処理です。

この関数は四つの関数呼び出しを行っていますが、いずれもObserverを用いたリスナ関数の登録処理です。四つの関数呼び出しを上から順に説明します。

(1)カレンダテーブル生成時の日付セルへのクラス名追加処理

スケジューラオブジェクトの、指定した日付に対する予定情報の有無を返す外部インタフェース用関数を用いて、指定した日付に対し予定情報があれば「予定情報あり」を示すクラス名を返す関数を生成し、それをカレンダオブジェクトの日付セルのクラス名生成イベント用リスナ関数として登録しています。この処理により、カレンダテーブル生成時に予定情報がある日付セルの見栄えを変更することを実現しています。

(2)日付セル選択時の予定情報表示処理

スケジューラオブジェクトの指定した日付の予定情報を表示する関数を、カレンダオブジェクトの日付選択イベント用リスナ関数として登録しています。この処理により、選択日を移動したときに該当する日付の予定情報を表示する処理を実現しています。

(3)予定情報追加時の日付セルへのクラス名追加処理

カレンダオブジェクトの指定日の日付セルへのクラス名追加処理関数(の生成関数によって生成される関数)をスケジューラオブジェクトの予定情報追加イベント用リスナ関数として登録しています。この処理により予定情報を追加したタイミングで対応する日付セルの見栄えを変更することを実現しています。

(4)予定情報削除時の日付セルからのクラス名除去処理

こちらは(3)「追加」「削除」になっただけで、ほとんど同じです。

カレンダオブジェクトの指定日の日付セルへのクラス名除去処理関数(の生成関数によって生成される関数)をスケジューラオブジェクトの予定情報削除イベント用リスナ関数として登録しています。この処理により予定情報を削除したタイミングで対応する日付セルの見栄えを変更することを実現しています。

今回のまとめ

三ページ目「予定情報管理機能の実装におけるポイント」で述べたとおり、今回のポイントは以下の四点です。

  • (1) 予定情報管理機能はcalendarオブジェクトとは別のオブジェクトにまとめた
  • (2) 予定情報の永続化にGM_setValue、GM_getValueを利用した
  • (3) 画像データはdataスキームを使ってスクリプト内に埋め込んだ
  • (4) オブジェクト間の連携のためにイベント通知処理用オブジェクトを作成した

(1)⁠4)はGreasemonkeyによるアプリケーション開発以外でも、オブジェクト指向プログラミングであればごく一般的なことだと思います。オブジェクトを適切な単位でまとめ、オブジェクト間の結合度をできるだけ粗に保つようにすることはプログラムをメンテナンスしやすくすることにつながります。

念のためですが、オブジェクトの連結にはObserverパターンを使いましょう、そのときは私が作成したObserverオブジェクトを使いましょう、ということではありません。必要に応じてデザインパターンを使うなりして粗結合を意識してコーディングすることがメンテナンスのしやすさにつながります、ということです。

(2)⁠3)はGreasemonkeyによるアプリケーションでは必要不可欠な処理になると思います。処理自体は非常に単純ですのでTIPSとして覚えておくとよいと思います。

次回の予告

次回はこのカレンダにGoogle Calendarに登録されている予定情報を表示させるようにしてみます。また、更なる機能向上のアイデアをいくつか提示して、読者の皆さんの夏休みの自由研究ネタに使っていただこうと思います。

おすすめ記事

記事・ニュース一覧