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

第4回Google Calendarの予定情報の取り込みと、さらなる拡張のアイデア

いよいよ今回で最終回です。

最終回の仕上げとして、Greasemonkeyの通信機能を利用して、これまで作成したカレンダアプリケーションにGoogle Calendar上の公開カレンダの情報を取り込む機能を追加していきます。

そして最後に、読者の皆様への練習問題として、更なる追加機能のアイデアをいくつか挙げたいと思います。

Google Calendarの予定情報の取り込み

Google CalendarのWeb API

Google Calendarは、皆さんご存知のGoogleが提供しているWeb上のカレンダアプリケーションサービスです。Google Calendara data APIと呼ばれるWeb APIも公開されていて、そのWeb APIを使うことで、予定情報の追加、参照、修正、削除が可能です。AtomをベースとしたXML形式のメッセージフォーマットが使われているのですが、参照についてはJavaScriptで扱いやすいJSONの形式でもカレンダの情報を取得できるようになっています。今回はこの機能を利用したいと思います(詳細な仕様についてはこちらを参照ください⁠⁠。

参照用のURLですが、その取得にちょっとだけ手間がかかります。その手順を簡単に説明します。

  • (1) Google Calendarを開く

  • (2) 参照したいカレンダのメニューを開き、⁠カレンダ設定」を開く

    (2)参照したいカレンダのメニューを開き、「カレンダ設定」を開く
  • (3) カレンダ設定画面で「このカレンダーを共有」タブを開く

    ※指定画像がありません※

  • (4) ⁠すべてのユーザと共有」の項目を「このカレンダーのすべての情報を一般に公開(検討の対象となる⁠⁠」に設定

    (4)「すべてのユーザと共有」の項目を「このカレンダーのすべての情報を一般に公開(検討の対象となる)」に設定
  • (5) 再びカレンダ設定画面を開き、⁠カレンダーのアドレス」の項目のXMLボタンをクリック

    (5)再びカレンダ設定画面を開き、「カレンダーのアドレス」の項目のXMLボタンをクリック
  • (6) 表示されたURLをコピー

    (6)表示されたURLをコピー
    コピーしたURL例
    http://www.google.com/calendar/feeds/gordon.timothy.nathanson%40gmail.com/public/basic
  • (7) 末尾を変換

    末尾の /basic を /full?alt=json に置き換えます。

    置き換えたあとのURL例
    http://www.google.com/calendar/feeds/gordon.timothy.nathanson%40gmail.com/public/full?alt=json

これで各予定の開始・終了日時や場所の情報をJSON形式で取得できます。

なお、予定情報を公開したくない場合は公開用に新たにカレンダを追加するとよいと思います。

また、自分で公開用カレンダを作成しなくても、公開されているカレンダを検索して、自分のカレンダとして取り込めば同じ手順でURLを取得することができます。

実装方針検討

JSON形式で予定情報を取得できる準備は整いました。あとは以下の手順で処理をすればカレンダアプリケーションに取り込むことができるでしょう。

  • (1) Google Calendar data APIにアクセスし、JSON形式で予定情報を取得する
  • (2) 取得したJSON文字列をevalし、予定情報を持つオブジェクトにする
  • (3) 予定情報オブジェクトから予定情報を取得し、スケジューラオブジェクトが管理する予定情報として取り込む
  • (4) スケジューラオブジェクトが外部から取り込んだ予定情報は編集/削除はできないようにする

(1)はGreasemonkeyが提供しているHTTPアクセス用関数GM_xmlhttpRequestを利用することで簡単に実装することができます。

ここで、GM_xmlhttpRequestの概説をしておきます。引数は以下の7つのフィールドを持つオブジェクトです。

method

HTTPのメソッドを文字列で指定します。必須項目。GET、POST、PUT、DELETEなど、HTTPのメソッドは何でも使えます。

url

アクセス先URLを文字列で指定します。必須項目。

headers

HTTPリクエストヘッダを、名前をフィールドとし、値をそのフィールドの文字列値として持つオブジェクトとして指定します。オプション項目。

例:ブラウザ上でフォームをPOSTで送信する動作をシミュレートする場合に必要なリクエストヘッダの指定
headers:{"Content-type":"application/x-www-form-urlencoded"}
data

HTTPリクエスト本体を文字列で指定します。オプション項目。

例:ブラウザ上でフォームをPOSTで送信する動作をシミュレートする場合
data:"q=" + encodeURIComponent("Greasemonkey アプリケーション gotin")
onload

リクエストが正常に終了した場合に呼ばれる関数を指定します。オプション項目。

onerror

リクエストの処理中にエラーが発生した場合に呼ばれる関数を指定します。オプション項目。

onreadystatechange

リクエストの処理中状態が変化したタイミングで呼ばれる関数を指定します。オプション項目。

onload、onerror、onreadystatechangeで指定する関数は以下の5つのフィールドを持つオブジェクトが引数として渡されます。

status

HTTPレスポンスのHTTPステータスコードを表す整数値。200なら正常終了、404ならNot Found、など。

statusText

サーバが返すステータステキストを表す文字列。ステータステキストはサーバにより返す文字列は変わる。

responseHeaders

HTTPレスポンスに含まれるHTTPヘッダーを表す文字列。リクエスト用ヘッダのフィールドheadersはオブジェクト形式だが、レスポンス用ヘッダはオブジェクトではなく文字列で返される。

responseText

HTTPレスポンス本体を表す文字列。

readyState

HTTPリクエストの段階を示す整数値。

  • 1:リクエスト準備中
  • 2:リクエスト準備完了
  • 3:リクエスト送信完了、レスポンス待機中
  • 4:レスポンス完了
利用例:
GM_xmlhttpRequest({
  method:"GET", 
  url:"http://www.google.com/calendar/feeds/gordon.timothy.nathanson%40gmail.com/public/full?alt=json",
  onload:function(x){alert(x.responseText)}
});

これで予定情報のJSON文字列をalertで出力することができます。図1はその主要部分の一部を示したものです。

図1 Google Calendar data APIのJSONフォーマットによるカレンダ情報の一部
{
    "version":"1.0",
    "encoding":"UTF-8",
    "feed":{"xmlns":"http://www.w3.org/2005/Atom",
         //中略
        "entry":[{
            "title":{"type":"text","$t":"第3回記事公開"},
            "content":{"type":"text"},
            "gd$when": [{
                "startTime":"2007-08-20",
                "endTime":"2007-08-21"}],
            "gd$where":[{}]},
                 :
  
                 :
}

(2)のJSON文字列のオブジェクト化はevalで行うことができます。ただし、JSON文字列の前後を()で囲む必要があります。

GM_xmlhttpRequest({
  method:"GET", 
  url:"http://www.google.com/calendar/feeds/gordon.timothy.nathanson%40gmail.com/public/full?alt=json",
  onload:function(x){
    alert(eval("(" + x.responseText + ")").feed.entry[0].title.$t);
  }
});

これで最初の予定情報のタイトルをalertで出力することができます。図1の場合であれば"第3回記事公開"が表示されるはずです。

この要領で予定情報を取得し、スケジューラオブジェクトが保持する予定情報として追加すれば、カレンダ上に表示することができます。

実装すべきことは確認できましたので、あとはコードにするだけです。

しかし、処理手順の、

  • (3) 予定情報オブジェクトから予定情報を取得し、スケジューラオブジェクトが管理する予定情報として取り込む
  • (4) スケジューラオブジェクトが外部から取り込んだ予定情報は編集/削除はできないようにする

の処理を実装するには既存の関数も修正する必要があります。

そこで、はじめに既存コードの修正点の概略を説明し、そのあとに新規に追加するGoogle Calendarからの予定情報の取り込み処理について説明します。

既存コードの修正点

既存コードの変更箇所は以下の通りです(変更内容の詳細はソースコードをご覧ください⁠⁠。

(1) 変更点

カレンダオブジェクト:
- makeAddClassNameToDateCell
(Date型の引数によって指定された日付に該当する日付セルにクラス名を追加する関数の生成関数)

生成される関数の引数を追加し、クラス名の追加時に該当する日付を選択する処理をするか否かを指定できるようにしました。外部から予定情報が追加されるタイミングはいつになるか分からない(Google Calendarサーバがレスポンスを返し、解析/追加処理がなされたタイミングになるため、カレンダの操作中に追加される可能性がある)ので、その都度日付選択処理がなされてしまうとユーザがカレンダを操作している際に勝手に選択操作がなされてしまい混乱の元になってしまいます。そこで、外部からの予定情報追加の処理の際にその動作を抑制できるようにするため、この修正を加えました。

スケジューラオブジェクト:
setButtonActions
(編集ボタンと追加ボタンの処理を設定する関数)

予定情報を表すオブジェクトに新たにeditable属性を追加し、その真偽値によって、その予定情報が編集/削除可能か否かを示すようにしました。外部から追加された予定情報であればその値をfalseにすることにより編集/削除ができないようにする狙いです。そのため、ここでは追加ボタンによる予定情報の追加時にeditable属性の値をtrueに設定するようにしました。

makeEditButton / makeDeleteButton
(編集ボタンの生成処理、削除ボタンの生成処理関数)

editable属性の値がfalseのときはボタンを生成しないようにしました。

addEvent
(予定情報の追加処理関数)

予定情報を保持する変数を外部から追加されたもののために別に用意することにしました。そこで、追加先をeditable属性の値で振り分けるようにし、外部からのものとそれ以外のもので別管理するようにしました。

getEvents
(指定された日付に予定情報を返す関数)

外部から追加された予定情報とカレンダアプリケーション内で生成した予定情報とをマージして返すようにしました。

deleteEvent
(予定情報の削除関数)

削除対象の予定の日時に登録されている別の予定情報が、外部から追加された予定情報にも存在しない場合に、当該日付に登録されている予定情報がなくなった旨を通知(deleteEventObserverのnotify関数呼び出し)するようにしました。

setStyle
(スタイルシートの設定処理関数)

外部から追加された予定情報と、カレンダアプリケーション内で生成した予定情報とでカレンダの日付セルの見栄えを返るため、外部から追加された予定情報に対するクラスを追加しました。

(2) 追加した処理

スケジューラオブジェクト:
importEvents

予定情報を表すオブジェクトの配列を引数で受け、外部から追加された予定情報として追加します。各要素のeventオブジェクトのeditable属性の値をfalseにした上で、外部予定情報管理用変数にセットするようにしました。

addAddOtherEventListener

外部から予定情報が追加されたときに呼び出されるリスナ関数を登録する関数です。外部から追加された予定情報の場合は見栄えを変えて表示するため、カレンダアプリケーション内で生成された予定情報の追加時のものとは別に用意しました。

hasOtherEvents

指定された日付に対する外部から追加された予定情報の有無を返す関数です。こちらも上記と同様の理由で用意しました。

以上で、外部から予定情報を追加できる、すなわちGoogle Calendarの予定情報を追加できる準備が整いました。

Google Calendarの予定情報を追加する処理の実装

図2にユーザスクリプトのGoogle Calendarの予定情報を解析する処理部分を示します。

図2 Google Calendarの予定情報を解析する処理(ユーザスクリプトはこちら
function googleCalendarEvents(url, callback){
  if(typeof callback != "function") return;
    GM_xmlhttpRequest({
      url:url,
      method:"GET",
      onload:function(xhr){
        callback(parse(xhr.responseText)); 
      }
    });
  /**
   * Google Calendarのカレンダ情報json文字列をオブジェクト化し、
   * スケジューラオブジェクトで管理する予定情報の形式のオブジェクトを要素とする配列として取り出す
   */
  function parse(json){
    var feed = eval("("+json+")").feed;
    var entries = feed.entry;
    var results = [];
    entries.forEach(function(entry){
      results.push(convertData(entry));
    });
    return results;

    function convertData(entry){
      var id = entry.id.$t;
      var title = entry.title.$t || "no title";
      var content = entry.content.$t || "";
      var where = entry.gd$where[0].valueString || "";
      var startDate = parseDate(entry.gd$when[0].startTime);
      var endDate = parseDate(entry.gd$when[0].endTime);
      var timeSpan = makeTimeSpan(entry);
      var description = title + (where ? "@" + where : "") + "<br />" + (timeSpan ? timeSpan + "<br />" : "") + content;
      return ({id:id,
        year:startDate.getFullYear() +"",
        month:(startDate.getMonth() + 1)  +"",
        date:startDate.getDate() +"",
        description:description});
      // 予定の開始日時、終了日時を予定情報の内容として設定するため、
      // その文字列表現を生成する
      function makeTimeSpan(entry){
        var startTimeS = entry.gd$when[0].startTime || "";
        var endTimeS = entry.gd$when[0].endTime || "";
        var startDate = parseDate(startTimeS);
        var endDate = parseDate(endTimeS);
        var startTime = "";
        var endTime = "";
        if(startTimeS.match(/T/)){
          startTime = getTime(startDate);
        }
        if(endTimeS.match(/T/)){
          endTime = getTime(endDate);
        }
        var timeSpan = "";
        if(startTime){
          timeSpan = startTime;
        }
        if(!startTime && !endTime && 
           endDate.getTime() - startDate.getTime() == 1000* 60 * 60 * 24){
          // nothing to do
        } else if(startDate.getTime() != endDate.getTime()){
          var formattedEndDate = "";
          if(startDate.getDate() != endDate.getDate() ||
             startDate.getMonth() != endDate.getMonth() ||
             startDate.getYear() != endDate.getYear()){
               formattedEndDate =  dateFormat(endDate) + " ";
          }
          timeSpan += "~" + formattedEndDate + endTime;
        }
        return timeSpan;
      }

      function parseDate(s){
        if(!s) return null;
        s = s.replace(/\-/g, "/").replace(/T/, " ").replace(/\.000/," GMT");
        return new Date(s);
      }
      
      function getTime(date){
        function format(n){ return n < 10 ? "0" + n : n;}
        return format(date.getHours()) + ":" + format(date.getMinutes());
      }
      
      function dateFormat(date){
        return date.getFullYear() 
          + "/" + (date.getMonth() + 1) 
          + "/" + date.getDate();
      }
    }
  }
}

googleCalendarEvents関数はGoogle Calendarの予定情報を解析する関数で、Google Calendar data APIのJSON文字列を取得できるURLと、解析の結果得られた予定情報を処理する関数(以下callback関数)を引数としてとります。

callback関数として、スケジューラオブジェクトに予定情報を追加する処理をする関数を渡せば、所望の処理ができることとなります。

関数内部ではJSON文字列をオブジェクト化し、そこから予定情報を抽出する処理を行っています。

Google Calendarでは予定情報として、タイトル、開始日時、終了日時、場所、内容などの情報も含んでいるのに対し、カレンダアプリケーションでは日付と内容だけを管理しています。

そこで、以下の形式の文字列をカレンダアプリケーションで管理している予定情報の内容の要素として保持するようにしました。

タイトル@場所<br />
開始時刻~終了日時<br />
内容

その処理をするのがconvertData関数で、makeTimeSpan関数で「開始時刻~終了日時」の文字列を生成する処理を行っています。

図3が、実際に図2のgoogleCalendarEvents関数を実行してGoogle Calendarの予定情報を登録する処理の実装部分です。

図3 Google Calendarの予定情報を登録する処理を追加したカレンダ表示/非表示処理関数の定義部分(ユーザスクリプトはこちら
var gPanel = null;
function toggleCalendar(){
  setPanelIfNeed();
  with(gPanel.style){
    display = (display != "block") ? "block" : "none";
  }
  
  function setPanelIfNeed(){
    if(gPanel) return;
    setGoogleCalendar();
    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 setGoogleCalendar(){
      var GOOGLE_CALENDAR_FEEDS = "http://www.google.com/calendar/feeds/";
      var PUBLIC_FULL = "/public/full?alt=json";
      var accounts = [// gotinの公開予定情報
                      "gordon.timothy.nathanson%40gmail.com",
                      // amazonの、プログラミング関連の本の発売日情報
                      "ujsbklv8riuihs10lq43ig2jptfjs5v7%40import.calendar.google.com", 
                      // amazonの、猫関連の本の発売日情報
                      "8s7v5t9jnhd418rvlnrlmskg346dsjlm%40import.calendar.google.com"];
      
      accounts.forEach(function(account){
        var url = GOOGLE_CALENDAR_FEEDS + account + PUBLIC_FULL;
        googleCalendarEvents(url, scheduler.importEvents);
      });
    }
    function connectCalendarAndScheduler(){
      var HAS_EVENTS_CLASS_NAME = css("has_events");
      var HAS_EX_EVENTS_CLASS_NAME = css("has_ex_events");
      calendar.addSetClassNameListener(function(date){
        return scheduler.hasEvents(date) ? HAS_EVENTS_CLASS_NAME // カレンダ内予定情報があれば黄色
          :scheduler.hasOtherEvents(date) ? HAS_EX_EVENTS_CLASS_NAME // 外部予定情報があれば緑色
          : "" ;
      });
      calendar.addSelectDateListener(scheduler.selectDate);
      scheduler.addAddEventListener(calendar.makeAddClassNameToDateCell(HAS_EVENTS_CLASS_NAME));
      scheduler.addDeleteEventListener(calendar.makeDeleteClassNameFromDateCell(HAS_EVENTS_CLASS_NAME));
      // 外部予定情報が追加されたら背景色を緑色に設定するクラスを追加する
      scheduler.addAddOtherEventListener(calendar.makeAddClassNameToDateCell(HAS_EX_EVENTS_CLASS_NAME));
    }
  }

  
  function setStyle(){
    var style =
      <><![CDATA[
     // 中略
                 #_gcal_ td._gcal_has_ex_events{
                   background-color:#99FF99;
                 }
     // 中略                 
                 ]]></>;
    GM_addStyle(style);
  }
}

カレンダの表示/非表示の処理そのものは全く変更していません。変更したのは以下の2点です。

(1) カレンダオブジェクトとスケジューラオブジェクトの接続処理

カレンダオブジェクトとスケジューラオブジェクトの接続処理では、外部からの予定情報はカレンダアプリケーションで生成した予定情報と区別して表示できるようにするために新たに追加した外部インターフェース用関数を使った接続処理を行っています。

日付セルの色づけにおいて、カレンダアプリケーションで生成した予定情報があるときは黄色にし、カレンダアプリケーションで生成した予定情報がなく、外部から追加した予定情報があるときは緑色になるようにしています。

(2) googleCalendarEvents関数を使ったGoogle Calendarの取り込み処理

setGoogleCalendar関数でGoogle Calendarの取り込み処理を行っています。

処理は単純で、三つのGoogle Calendar data APIのURLを定義し、一つずつgoogleCalendarEvents関数を呼び出し、スケジューラオブジェクトのimportEventsで追加処理させるようにしています。

なお、⁠gotinの公開予定情報」のURLは1ページ目の「Google CalendarのWeb API」の箇所で説明した方法で取得しましたが、⁠amazonの、プログラミング関連の本の発売日情報」「amazonの、猫関連の本の発売日情報」Amazon2ical 発売日をカレンダーで表示!のサービスを利用してiCalを生成し、それをGoogle Calendarに登録し、その上で同様の方法で取得しました。

Google Calendarの予定情報の取り込みに関するまとめ

今回のポイントはGM_xmlhttpRequestによる通信処理でした。GETメソッドでGoogle Calendar APIのJSONを取得するだけの単純な使い方でしたが、他のメソッドもサポートしていますし、もっと複雑な処理も実装可能です。

手前味噌ですが、私が過去に作成した、通信機能を活用したアプリケーションとして、以下の四つのものを参考例として挙げておきます。

各々のユーザスクリプトの詳細についてはリンク先のページをご覧ください。LingrもTwitterもいずれもJSON形式をサポートしているWeb APIが公開されており、Greasemonkeyのユーザスクリプトから利用しやすくなっています。仮にXML形式のみがサポートされているものであっても、FirefoxのXMLパーサ機能を利用できるので、それほど難しくはありません。

さて、通信処理が自由に使える、XMLも使える、となるとほぼすべてのWeb APIを利用可能だと言っても過言ではないでしょう。Greasemonkeyを使うことでJavaScriptだけでWeb APIが使え、しかも開いているページとも連携できてしまうわけです。読者の皆さんも、ユーザスクリプトのアイデアが湧きはじめることと思います。是非何かアプリケーションを作ってみましょう。その第1歩として、次ページに拡張ネタをいくつか用意しました。

カレンダアプリケーションの拡張アイデア

Google Calendarの取り込みまでできるようになったカレンダアプリケーションですが、まだまだ改善の余地が残されています。以下10個の拡張アイデアを挙げました。筆者の独断による難易度をの数で3段階につけておきました。

(1) 日付入力のvalidation

現状は何も入力値のチェック処理をしていないので、ありえないほど大きな数値や数字以外の文字列などの入力されると正常に動作する保証がありません。入力値をチェックし、利用不可能な値が入力されている場合は更新処理を進めず、警告を出したり入力不可能になるようにするとユーザビリティが高まると思います。

(2) クリックでも日付選択

現状はカーソルキーで日付を移動(選択)できるようにしています。マウスクリックで選択できるようにすると、予定情報の追加、修正時に便利になると思います。

(3) 予定情報の追加、修正、削除の処理にもキーバインドを割り当てる

現状は追加/修正/削除の操作をするためにマウスクリックが必要になっています。これらの操作にもキーバインドを割り当てるとより便利になりそうです。

(4) 全予定表示

現状は予定の内容は日付を選択しないと表示されません。全予定内容表示ボタンをつけて、それを押したら全部表示できるようにしてみましょう。

(5) RSS Reader ★★

実は本記事の執筆内容の検討当初はGoogle Calendarではなく、ブログなどのRSSの情報をカレンダに表示できるようにしようと考えていました。しかし、毎日エントリのあるRSSだとカレンダが全部埋め尽くされてしまい予定情報ありなしがよくわからなくなってしまうことを懸念し、今回の方向性に決めました。表示方法を工夫すればよいかもしれません。

表示中のページがRSSを配信している場合はそれを取り込む操作をするためのボタンをカレンダ画面に追加する、などの実装も可能です。

なお、かつてLivedoor Readerをまねた(まねしようとした)GreasemonkeyによるRSSリーダーを作ってみたこともありました。

(6) 設定機能 ★★

現状、googleカレンダのURLがスクリプトに直接埋め込まれた形になっています。アプリ画面からURLを設定できるほうが便利です。そのための設定画面を作成してみましょう。変数accountsの値としてURLの配列がソースに埋め込んであるわけですが、GM_setValue/getValueでその値を設定/取得できるようにすればよいわけです。

なお、こうした設定機能をつけると、設定した直後にすぐに反映されるようにしたいところです。そうすると追加だけでなく削除も反映したくなるので、いったん全イベントを削除して、リロードする処理が必要になります。

(7) インポートボタン ★★

(5)のRSSリーダーのアイデアと同様ですが、Google Calendarの「カレンダの設定」画面内にそのカレンダをインポートするボタンがあると便利になりそうです。XMLボタンにURLがありますから、それを少し加工すればインポート用のURLになります。

(8) Google Calendarの更新もできるようにする。 ★★★

Google Calendar data APIは、認証処理も可能になっており、認証を通せば更新もできるようになっています。予定情報をすべてGoogle Caelndar上に配置してしまえば、異なるコンピュータ上のFirefoxでも予定情報を同期して扱えるようになります[1]⁠。

(9) 画面いっぱいにカレンダ表示する ★★★

現状、常にカレンダを小さく表示することにこだわったので、予定情報をカレンダ内で一望する、ということができなくなっています。そこでGoogle Calendarと同じように、カレンダを画面いっぱいに表示し、日付セルの中に複数の予定情報が並ぶよう表示し、さらにドラッグ&ドロップによる予定情報の移動などにも対応するようにするとよいでしょう[1]⁠。

(10) 表示中のページ内に記載されている予定情報を取り込む ★★

表示中のページ内に予定情報が記載されている場合はそれを自動的に認識してカレンダに取り込めるようになっていると非常に便利です。自然言語処理を実装する必要があるわけですが、microformatsのhCalendar形式で記載されている予定情報であれば比較的簡単に取り込み処理を実装することはできると思います。

最後に

Greasemonkeyは様々なアイデアを手軽に実現するのによい環境だと思いますので、是非ユーザスクリプト開発にチャレンジしてください。

なお、本稿でサンプルとして作成、公開したカレンダアプリケーションユーザスクリプトは引き続き筆者のブログで拡張を続ける可能性があります。もしご興味がございましたら、筆者のブログRSSをお手持ちのRSSリーダーにご登録いただき、更新状況(及び私の独り言)を確認いただければ幸いです。

おすすめ記事

記事・ニュース一覧