Author Archives: misohena

2016-06-14

過去の紙資料やメモ書きをスキャン中

ここ数日過去の紙資料をスキャンして処分しています。部屋の空きスペースを増やしたかったので。

手帳、仕事の印刷資料、手書きのメモなんかが主ですが、これが意外と量があって百科事典何冊分かにはなりそうです。紙を閉じているファイルなんかは無駄にかさばりますしね。

手帳は大学生くらいから2010年くらいまでありました。2009年くらいからあまり書かなくなっていて、2010年はほとんど書いてません。TODO管理用テキストの最初の項目が2010年から始まっているので、あまり必要なくなったのかもしれません。Android端末(HT-03AやSO-01B Xperia)を使い始めたのもこのあたりなので、それも関係しているかもしれません。まぁ、色々と行き詰まっていたというのもあるのかもしれません。

いつの頃からかA4コピー用紙でメモを書く習性がつきました。職場の机の右の引き出しには常に白紙のコピー用紙が大量に入っていて、何か考えるときはサッと取り出して考えをまとめながら書き、上にタイトルと日付を書いてパンチで穴を開けてファイルに閉じていました。そういうのがプロジェクト毎に沢山あります。最近は出来るだけデジタルだけで済ますようにしていますし、紙に書いたときでも最後はScanSnapでスキャンして紙の方は速やかに処分するようにしています。

そんなことで昔書いたものを見るわけですが、色々感じるところがありますね。なんだか切なくなったり、色々考えてるんだぁと感心したり、脳の中の忘れていた部分が刺激されてなんだか可能性が広がったような錯覚があったり。

まぁ、部屋は広くなるので新しいものが置けるようにはなるでしょう。

こういう日記もそうなのですが、基本的に自分が興味があることを書いているので後で読み返しても(自分にとっては)面白いんですよね。

2016-06-05 ,

Google Calendarで過ぎた日の色を変える

カレンダーの過ぎた日に斜線を引くのが好きな私としては、過ぎた日が一目で分かるようにして欲しいのです。

例えば次のようなユーザースクリプトを作ってGreasemonkeyなりTampermonkey使えば出来るわけですが、まぁ、やりたいことの些細さに比べて面倒ですね。

(追記2018-11-14: デザインが変わって動かなくなっていたので Google Calendarで過ぎた日の色を変える(2018) を書きました)

(function(){
    var PAST_COLOR = "#e0e0e0";
    var PAST_BORDER_LEFT = "1px solid #d0d0d0";
    var TODAY_COLOR = "#ffa";

    var timer = null;
    function schedule(){
        if(timer){
            clearTimeout(timer);
        }
        timer = setTimeout(update, 100);
    }

    function update(){
        if(document.getElementsByClassName("st-bg-today").length === 0){
            return;
        }

        var bgs = Array.prototype.slice.call(document.getElementsByClassName("st-bg"));
        bgs.find(function(e,i,a){
            if(e.className.match("st-bg-today")){
                e.style.backgroundColor = TODAY_COLOR;
                return true;
            }
            else {
                //e.className += " st-bg-past";
                e.style.backgroundColor = PAST_COLOR;
                e.style.borderLeft = PAST_BORDER_LEFT;
                return false;
            }
        });
        var dtitles = Array.prototype.slice.call(document.getElementsByClassName("st-dtitle"));
        dtitles.find(function(e,i,a){
            if(e.className.match("st-dtitle-today")){
                e.style.backgroundColor = TODAY_COLOR;
                return true;
            }
            else {
                //e.className += " st-dtitle-past";
                e.style.backgroundColor = PAST_COLOR;
                e.style.borderLeft = PAST_BORDER_LEFT;
                return false;
            }
        });
    }

    var observer = new MutationObserver(schedule);
    observer.observe(document.body, {childList:true, subtree:true});

    update();
})();

月表示や4週間表示の時、各日付の枠(td)には st-bg というクラスが付いています。 今日の日付をグレーにするためか、 st-bg-today というクラスも用意されていて、 今日の日付の枠は st-bg st-bg-today というクラスが付きます。

なので、クラスst-bgが付いている要素をgetElementsByClassNameで列挙して、 先頭からst-bg-todayが付いている要素に到達するまで背景色を変えることにしました。

今日の日付(st-bg-today)がカレンダー内にない場合もあります。 本来なら、過去のカレンダーか未来のカレンダーかを判別して、 全部色を変えるか全く色を変えないか判断しなければなりません。 面倒なので、st-bg-todayが無ければ何もしないようにしました。

日付の数字部分(td.st-dtitle)の背景には不透明な白が指定されています。 そのままだとせっかく背景の色を変えても数字部分だけ白くなってしまいます。 transparentを指定したら良いのかと思ったのですが、 カレンダーの1行がわずかに次の行と重なっているため、 数字部分の上の方が前の行の背景色になってしまいます。 div.month-rowのheightが4行の時は26%、5行の時は21%となっていて、 これを25%や20%にするとぴったり収まります。 なぜ1%余計に大きくしているのでしょうか。 無理矢理1%小さくする方法も考えたのですが、 Googleは今日の日付の色を変えるために.st-bg-todayと.st-dtitle-todayの両方の backgroundを指定していますので、それにならって.st-dtitleの色も.st-bg と同様に変更することで解決しました。

最初はst-bg-pastとst-dtitle-pastというクラスを付加するようにしたのですが、 別途Stylish等でユーザースタイルを指定するのが面倒だったので 直接ユーザースクリプトで色を変えるようにしてしまいました。

MutationObserverのコールバック頻度はどのように決まっているのでしょうか。 今ひとつ分からなかったので、タイマーを併用して最後の更新から100ms後に 色替えを実行することにしました。

Googleのプロダクトはかゆいところに全く手が届かないのが困りものですよね。 あまり細かいカスタマイズが必要ならGoogle Calendar APIを使用して 自分でカレンダーを作るなり、他の既成のカレンダーを探すなりした方が良いかも しれません。

FullCalendar - JavaScript Event Calendar (jQuery plugin)

2016-05-29 ,

org-mode文書中の予定(タイムスタンプ)をGoogle Calendarへ登録する

Google Calendarとやりとり出来るようになったので、org-modeとGoogle Calendarとの間で必要となる道具立てを作成しています。

色々作成中ですがとりあえず。

misohena/gcal: Google Calendar Utilities for Emacs

gcal-org.el というのが中心になるのですが、その中のgcal-oeventオブジェクトがGoogle Calendarへ登録される予定を表します。

gcal-oeventオブジェクト

gcal-oevent(以下単にoevent)は、org文書を解析して得られた1つの予定(event)を表現するオブジェクトです。

オブジェクトの作成

オブジェクトは、例えば以下のようなコードで作成できます。

(setq oe
  (make-gcal-oevent
    :id "a84717b2-7c0b-4549-80f4-9477d14f975f"
    :ord 0
    :summary "田中さんと打ち合わせ"
    :ts-prefix "DEADLINE"
    :ts-start '(2016 2 3 nil nil)
    :ts-end '(2016 2 4 nil nil)
    :location "東京駅"))

また、org文書を解析して(そのリストが)得られたり、

(gcal-org-parse-file "~/my-schedule.org") ;;指定ファイルから
(gcal-org-parse-buffer) ;;現在のバッファから

オブジェクトをファイルへ保存して、それを読み込んで得られたり、

;; my-schedule.orgを解析したものをmy-schedule.gcal-cacheへ保存
(gcal-oevents-save "~/my-schedule.gcal-cache" (gcal-org-parse-file "~/my-schedule.org"))

;; my-schedule.gcal-cacheからoeventのリストを読み込み
(gcal-oevents-load "~/my-schedule.gcal-cache")

Google Calendar上のカレンダーから読み込んで得られたりします。

;; カレンダー(ID:example@gmail.com)から予定を読み込んでoeventのリストとして返す
(gcal-org-pull-oevents "example@gmail.com")

プロパティの取得

プロパティの取得は (gcal-oevent-プロパティ名) で行えます。

(gcal-oevent-id oe) ;;=> "a84717b2-7c0b-4549-80f4-9477d14f975f"
(gcal-oevent-summary oe) ;;=> "田中さんと打ち合わせ"

1オブジェクト=1タイムスタンプ

このオブジェクトはorg文書中の一つの(アクティブ)タイムスタンプに対応します。一つのヘッドラインに対応するのでは ありません 。 org-agendaが行うように、SCHEDULED、DEADLINE、その他記事中のアクティブタイムスタンプ一つにつき一つの予定をGoogle Calendarへ登録したかったからです。

そのため :id の他に :ord というプロパティを持っています。このプロパティは一つのエントリー中に現れたアクティブタイムスタンプの順番を表します。

IDの変換

Google Calendarの予定のIDはbase32hexで使う範囲の文字(0-9a-v)しか受け付けません(ハイフンが入っているとダメです)。 また、デフォルトではUUIDをbase32hexでエンコードしたものをIDとしています。

従って、oeventをGoogle Calendarへ登録するときは:idをbase32hexでエンコードしたものへ変換することにしました。

(downcase (gcal-uuid-to-base32hex "a84717b2-7c0b-4549-80f4-9477d14f975f"))
;; => "l13hfcjs1d2kj07kihrt2jsnbs"

(gcal-uuid-from-base32hex "l13hfcjs1d2kj07kihrt2jsnbs")
;; => "a84717b2-7c0b-4549-80f4-9477d14f975f"

しかしこれだけだと1エントリー中に複数のタイムスタンプがある場合にIDが重複してしまいます。

それを避けるために :ord が1以上の時は、その数をIDの後ろに付加することにしました。幸い文字数の制限は緩いので。

(gcal-oevent-gevent-id
  (make-gcal-oevent
    :id "a84717b2-7c0b-4549-80f4-9477d14f975f"
    :ord 1
    ... )) ;;=> "l13hfcjs1d2kj07kihrt2jsnbs00001"

(gcal-oevent-id-ord-from-gevent-id "l13hfcjs1d2kj07kihrt2jsnbs00001")
;; => ("a84717b2-7c0b-4549-80f4-9477d14f975f" . 1)

:ord が0のときは付加しません。Google Calendar上で作成した予定を取り込むときはその方が都合が良いからです。取り込んだイベントをorg文書中にインポートした後、元のGoogle Calendar上のイベントのIDを番号付きに修正する必要が出てしまいますので。

タイムスタンプの扱い

:ts-start や :ts-end は ( ) というリストで表現します。このリストは gcal-ts- で始まる関数で色々な処理が出来るようになっています。

はnilの場合があります。時間の入っていないタイムスタンプに対応します。

(gcal-ts-date-only '(2016 5 29 nil nil)) ;;=> t <2016-05-29 Sun>
(gcal-ts-date-only '(2016 5 29 12 34)) ;;=> nil <2016-05-29 Sun 12:34>

ややこしいのはts-endの扱いで、Google Calendar側では終了時刻はその時刻自身を含まない(exclusive)とされていますが、org-mode側では曖昧であることです。 org-modeは日付のみの場合はinclusive、時刻を含む場合はexclusiveなのだと思うので、それを前提に変換しています。

(gcal-ts-end-exclusive '(2016 5 1 nil nil) '(2016 5 1 nil nil)) ;; => '(2016 5 2 nil nil) 1day <2016-05-01 Sun>
(gcal-ts-end-exclusive '(2016 5 1 nil nil) '(2016 5 2 nil nil)) ;; => '(2016 5 3 nil nil) 2days <2016-05-01 Sun>--<2016-05-02 Mon>
(gcal-ts-end-exclusive '(2016 5 1 10 00) '(2016 5 1 10 00)) ;; => '(2016 5 1 10 0) 0hour <2016-05-01 Sun 10:00>
(gcal-ts-end-exclusive '(2016 5 1 10 00) '(2016 5 1 12 00)) ;; => '(2016 5 1 12 0) 2hours <2016-05-01 Sun 10:00-12:00>
(gcal-ts-end-exclusive '(2016 5 1 10 00) '(2016 5 2 10 00)) ;; => '(2016 5 2 10 0) 24hours <2016-05-01 Sun 10:00>--<2016-05-02 Mon 10:00>
;;逆は gcal-ts-end-inclusive

oeventとGoogle Calendarとのやりとり

oeventをGoogle Calendar APIへ送る(gcal-events-insertへ渡す)ための形式へ変換するには gcal-oevent-to-gevent を、その逆は gcal-oevent-from-gevent を使用します。

;; Send a oevent
(gcal-events-insert "example@gmail.com" (gcal-oevent-to-gevent oevent))

;; Receive a event as oevent
(gcal-oevent-from-gevent (gcal-events-get "example@gmail.com" "l13hfcjs1d2kj07kihrt2jsnbs"))

oeventのリストは gcal-org-push-oevents と gcal-org-pull-oevents で送受信できます。

;; Send all active timestamps in my-schedule.org
(gcal-org-push-oevents "example@gmail.com" (gcal-org-parse-file "~/my-schedule.org") nil)

;; Receive
(gcal-org-pull-oevents "example@gmail.com")

gcal-org-push-oeventsは差分抽出機能を備えており、旧リストから新リストへの差分(追加、削除、更新)のみをGoogle Calendarへ送ることが出来ます。

;; my-schedule.orgだけにあるものを追加し、.oldだけにあるものを削除し、内容が変わったものをパッチする。
(setq result-events
  (gcal-org-push-oevents
    "example@gmail.com"
    (gcal-org-parse-file "~/my-schedule.org") ;;new-events
    (gcal-org-parse-file "~/my-schedule.org.old");;old-events
))
;; result-eventsはサーバ上に残った予定のリスト。
;; エラーが無ければnew-eventsと同じ。
;; 追加に失敗すればその予定を載らないし、更新に失敗すればその予定は旧状態、削除に失敗すればその予定は残る。

;; my-schedule.orgにある予定を全て削除する。
;; new-eventsがnil(空リスト)なので、old-events内の予定に対応するものは一つもないので。
(gcal-org-push-oevents
  "example@gmail.com"
  nil
  (gcal-org-parse-file "~/my-schedule.org"))

oeventとOrg文書とのやりとり

org文書からoeventのリストを得る(解析する)には次のようにします。

(gcal-org-parse-file "~/my-schedule.org")
(gcal-org-parse-buffer)

逆にoeventを文字列化してorg文書へ挿入するには次のようにします。 (※ただし、:ordのことを考慮していないので注意が必要です)

;; 挿入先のorg文書を開いた状態で実行すること
(gcal-org-insert-string-after-headline
  (gcal-oevent-format oevent)
  "Inbox")

gcal-oevent-format は何も引数を指定しなければ gcal-org-oevent-template 変数に書いてあるテンプレートを使ってoeventを文字列化します。

gcal-org-oevent-template
"** %{summary}\nSCHEDULED: %{timestamp}\n:PROPERTIES:\n :ID: %{id}\n :LOCATION: %{location}\n:END:\n"
  "org-mode text representation of oevent."

Org文書とGoogle Calendarとのやりとり

oeventオブジェクトを介さずに直接やりとりする関数も作っています。

;; キャッシュ~/my-schedule.gcal-cacheから~/my-schedule.orgへの差分を
;; カレンダーexample@gmail.comへ適用します。
;; 成功した更新はキャッシュへ反映されます。
(gcal-org-push-file "example@gmail.com" "~/my-schedule.org" "~/my-schedule.gcal-cache")

;; キャッシュmy-schedule.gcal-cacheからカレンダーexample@gmail.comへの差分を
;; ~/my-schedule.orgへ適用します。
;; 新しく追加された予定はヘッドライン"Inbox"の下に挿入します。
;; 成功した更新はキャッシュへ反映されます。
(gcal-org-pull-to-file "example@gmail.com" "~/my-schedule.org" "Inbox" "~/my-schedule.gcal-cache")

pullは作り途中で、更新に対応していません(2016-05-29現在)。org文書中の一部の場所だけ書き換えないといけないので。

oeventsリストの差分抽出

gcal-oevents-diffを使用すると二つのoeventリストを比較できます。

それぞれの予定を :id と :ord をキーにマッチングを行い、変化(追加、削除、変更、そのまま)を見つけ出し、それに対応する関数を呼び出します。

(gcal-oevents-diff
 (gcal-oevents-load "~/my-schedule.org") ;;old-events
 (gcal-org-pull-oevents "example@gmail.com") ;;new-events
 (lambda (old-oe new-oe) (insert (format "mod %s => %s\n" old-oe  new-oe)))
 (lambda (new-oe) (insert (format "add %s\n" (gcal-oevent-summary new-oe))))
 (lambda (old-oe) (insert (format "del %s\n" (gcal-oevent-summary old-oe))))
 (lambda (oe) (insert (format "not change %s\n" (gcal-oevent-summary oe)))))
2016-05-26 ,

EmacsからGoogle Calendar APIにアクセスする

org-modeでタスク管理をしているとGoogleカレンダーとの同期がしたくなりますよね。

現在はMobileOrg(Android版)のカレンダー登録機能を使っているのですが一定時間おきに同期するので予定がすぐ見られないことが良くありますし、AndroidのMobileOrg自体出来があまり良くなくて同期に失敗していつの間にか止まっていたりします。

org-modeのマニュアルではiCalendar(.ics)へエクスポートしてサーバに置いておく方法が紹介されていますがGoogleのボットが見に行くのに時間がかかるのであまり即時性はありません。

org-gcal.elを試したのですがエラーで動かないことやリクエストが終わらずそのまま何も起こらなかったりしてしまいました(予定はダウンロードできる(たまにできない)がアップは出来ない)。それと私がやりたかったのはagendaで出てくるような複数ファイル上の予定をそのままGoogleカレンダーへ設定したいだけだったので、わずかに使い方が異なるというのもありました。

動かない原因を探そうにも前提知識無しでorg-gcal.elをいきなり読んでもよく分かりません。

というわけで、勉強がてら色々参考にしながらEmacs LispでGoogle Calendar APIにアクセスするコードを書いてみました。

Google APIライブラリが整備されている他の言語や外部のツール(GoogleCL)を使うという手もあるのですがそこはとりあえずWindowsだから面倒ということで。

Google Developer Consoleで認証情報を作成

これから作るelisp(クライアントアプリ)がGoogleカレンダーにアクセスできるように、Google Developer Consoleで認証情報を作成する必要があります。

手順は次の通りです。

  1. Google Developer Consoleへアクセス
  2. プロジェクトの作成(プロジェクト名はCalendar Access From Emacsとでもした)
  3. 概要→Calendar API→有効にする→認証情報に進む
  4. プロジェクトへの認証情報の追加画面
    1. 必要な認証情報の種類を調べる
      • 必要な認証情報の種類「Google Calendar API」
      • APIを呼び出す場所「その他のUI(Windows、CLIツールなど)
      • アクセスするデータの種類「ユーザーデータ」
    2. OAuth 2.0 クライアントIDを作成する
      • 名前「gcal.el」
    3. OAuth 2.0 同意画面を設定する
      • メールアドレスとユーザーに表示するサービス名を適当に設定
    4. 完了
  5. 認証情報→クライアントの名前部分をクリック 必要な情報を確認
    • クライアントID (ex: 1234-xxxxxxxxx.apps.googleusercontent.com)
    • クライアントシークレット (ex: Xxx1xXxxXxx2xxXxxxXxx3xXxxx)

url-retrieve-synchronouslyでHTTPアクセス

request.elも魅力的なのですが今回はurl-retrieve-synchronouslyを使ってみます。

url-retrieve系は色々おかしな挙動を示すこともありますが基本的な使い方は簡単です。

クエリ文字列をPOSTしてレスポンス全体を文字列で受け取るには次のようにします。(Http Post - EmacsWikiより)

(require 'url)

(let ((url-request-method        "POST")
      (url-request-extra-headers `(("Content-Type" . "application/x-www-form-urlencoded")))
      (url-request-data          "field1=Hello&field2=from&field3=Emacs"))
  (with-current-buffer (url-retrieve-synchronously url)
    (buffer-string)))

また、今回はjsonの処理を多用します。jsonとlispオブジェクトとの変換は、Emacsに標準で入っているjson.elを使用します。

(require 'json)

(json-read-from-string "{\"name\": \"Taro\", \"age\": 18}")
 ;;=> ((name . "Taro") (age . 18))

(json-encode '((name . "Taro") (age . 18)))
 ;;=> "{\"name\":\"Taro\",\"age\":18}"

実際には文字列はutf-8でやりとりする必要があるので、encode-coding-stringやdecode-coding-stringで変換してやる必要があります。環境(標準のcoding system)によるのかもしれませんが私の所では送受信とも変換してやる必要がありました。

(json-read-from-string (decode-coding-string response-body 'utf-8))

(encode-coding-string (json-encode json-obj) 'utf-8)

これらを統合して、本プロジェクトでHTTPにアクセスするために使う関数をこしらえました。

主に使うのは gcal-retrieve-json- で始まる三つの関数です。

  • (gcal-retrieve-json-get url params)
  • (gcal-retrieve-json-post-www-form url params)
  • (gcal-retrieve-json-post-json url params obj)

これらは url にリクエストを出して返ってきたレスポンスをJSONとして解釈してその結果をlispオブジェクトで返します。

メソッドをGETにするかPOSTにするか、また、POSTにするなら何をPOSTするかで三つのバリエーションを用意しました。

関数の実装は次の通りです。

;; HTTP

(defun gcal-http (method url params headers data)
  (let ((url-request-method (or method "GET"))
        (url-request-extra-headers headers)
        (url-request-data data))
    (gcal-parse-http-response
     (url-retrieve-synchronously (gcal-http-make-query-url url params)))))

(defun gcal-parse-http-response (buffer)
  "バッファ内のHTTPレスポンスを解析して、ステータス、ヘッダー、ボディ等に分割します。"
  (with-current-buffer buffer
    ;; Response Line (ex: HTTP/1.1 200 OK)
    (beginning-of-buffer)
    (if (looking-at "^HTTP/[^ ]+ \\([0-9]+\\) ?\\(.*\\)$")
        (let ((status (string-to-number (match-string 1)))
              (message (match-string 2))
              (headers)
              (body))
          (next-line)
          ;; Header Lines
          (while (not (eolp))
            (if (looking-at "^\\([^:]+\\): \\(.*\\)$")
                (push (cons (match-string 1) (match-string 2)) headers))
            (next-line))

          ;; Body
          (next-line)
          (setq body (buffer-substring (point) (point-max)))

          ;; Result
          ;;(push (cons ":Body" body) headers)
          ;;(push (cons ":Status" status) headers)
          ;;(push (cons ":Message" message) headers)
          (list status message headers body)
          ))))

(defun gcal-http-get (url params)
  "指定されたurlへparamsをGETします。"
  (gcal-http "GET" url params nil nil))

(defun gcal-http-post-www-form (url params)
  "指定されたurlへparamsをPOSTします。"
  (gcal-http "POST" url nil
             '(("Content-Type" . "application/x-www-form-urlencoded"))
             (gcal-http-make-query params)))

(defun gcal-http-post-json (url params json-obj)
  "指定されたurlへjsonをPOSTします。"
  (gcal-http "POST" url params
             '(("Content-Type" . "application/json"))
             (encode-coding-string (json-encode json-obj) 'utf-8)))


(defun gcal-retrieve-json-get (url params)
  "指定されたurlへparamsをGETして得られるjsonを解析したリストを返します。"
  (gcal-http-response-to-json (gcal-http-get url params)))

(defun gcal-retrieve-json-post-www-form (url params)
  "指定されたurlへparamsをPOSTして得られるjsonを解析したリストを返します。"
  (gcal-http-response-to-json (gcal-http-post-www-form url params)))

(defun gcal-retrieve-json-post-json (url params json-obj)
  "指定されたurlへparamsとjsonをPOSTして得られるjsonを解析したリストを返します。"
  (gcal-http-response-to-json (gcal-http-post-json url params json-obj)))


(defun gcal-http-response-to-json (response)
  "レスポンス(gcal-http, gcal-parse-http-responseの戻り値)のボディをjson-read-from-stringします。"
  (let* ((status (nth 0 response))
         (body (nth 3 response)))
    ;;@todo check status
    (json-read-from-string (decode-coding-string body 'utf-8))))

(defun gcal-http-make-query (params)
  "クエリ文字列を作成します。(ex: a=1&b=2&c=3)"
  (url-build-query-string params))

(defun gcal-http-make-query-url (url params)
  "クエリ付きのURLを作成します。(ex:http://example.com/?a=1&b=2&c=3)"
  (let* ((query (gcal-http-make-query params)))
    (if (> (length query) 0) (concat url "?" query) url)))

OAuth 2.0で認証

カレンダーにアクセスするには認証が必要です。認証はOAuthで行います。

やることはだいたい次の通りです。

  1. 認証ページをWebブラウザで開いてユーザーに認証してもらい、認証ページに表示された認証コードをEmacsに入力してもらう。
  2. 認証コードからトークン情報(アクセストークン、失効までの時間、リフレッシュトークン)を取得する。
  3. 失効時間が来たらリフレッシュする(リフレッシュトークンを使って新しいアクセストークンと失効までの時間を取得する)。
  4. トークン情報を保存・復帰する。 (面倒なので今回はパス)
;;
;; OAuth
;; (この部分は一応Google Calendar以外でも使い回せるように作っています)
;;
;; Example: (setq token (gcal-oauth-get token "https://accounts.google.com/o/oauth2/auth" "https://www.googleapis.com/oauth2/v3/token" "xxx.apps.googleusercontent.com" "secret_xxx" "https://www.googleapis.com/auth/calendar"))

(defstruct gcal-oauth-token access expires refresh url)

(defun gcal-oauth-get (token auth-url token-url client-id client-secret scope)
  "アクセストークンを取得します。必要なら認証やリフレッシュを行います。"
  (if token
      (if (time-less-p (gcal-oauth-token-expires token) (current-time))
          (setq token (gcal-oauth-refresh token client-id client-secret token-url)))
    (setq token (gcal-oauth-auth auth-url token-url client-id client-secret scope)))

  token)

(defun gcal-oauth-auth (auth-url token-url client-id client-secret scope)
  "OAuthによりアクセストークンを取得します。gcal-oauth-token構造体を返します。"
  (let* ((result (gcal-oauth-get-access-token auth-url token-url client-id client-secret scope))
         (access-token (cdr (assq 'access_token result)))
         (expires-in (cdr (assq 'expires_in result)))
         (refresh-token (cdr (assq 'refresh_token result)))
         (expires (time-add (current-time) (seconds-to-time expires-in))))
    (make-gcal-oauth-token
     :access access-token
     :expires expires
     :refresh refresh-token
     :url token-url)))

(defun gcal-oauth-refresh (token client-id client-secret &optional token-url)
  "gcal-oauth-token構造体のアクセストークンをリフレッシュします。"
  (let* ((result (gcal-oauth-get-refresh-token
                  (gcal-oauth-token-refresh token)
                  (or token-url (gcal-oauth-token-url token))
                  client-id client-secret))
         (access-token (cdr (assq 'access_token result)))
         (expires-in (cdr (assq 'expires_in result)))
         (expires (time-add (current-time) (seconds-to-time expires-in))))
    (when (and access-token expires)
      (setf (gcal-oauth-token-access token) access-token)
      (setf (gcal-oauth-token-expires token) expires)))
  token)


   ;; implementation details
(defun gcal-oauth-get-access-token (auth-url token-url client-id client-secret scope)
  "アクセストークンを取得します。JSONをリストへ変換したもので返します。"
  (gcal-retrieve-json-post-www-form
   token-url
   `(
     ("client_id" ,client-id)
     ("client_secret" ,client-secret)
     ("redirect_uri" "urn:ietf:wg:oauth:2.0:oob")
     ("grant_type" "authorization_code")
     ("code" ,(gcal-oauth-get-authorization-code auth-url client-id scope)))))

(defun gcal-oauth-get-authorization-code (auth-url client-id scope)
  "ブラウザを開いてユーザに認証してもらい、認証コードを受け付けます。"
  (browse-url
   (concat auth-url
           "?client_id=" (url-hexify-string client-id)
           "&response_type=code"
           "&redirect_uri=" (url-hexify-string "urn:ietf:wg:oauth:2.0:oob")
           "&scope=" (url-hexify-string scope)))
  (read-string "Enter the code your browser displayed: "))

(defun gcal-oauth-get-refresh-token (refresh-token token-url client-id client-secret)
  "リフレッシュされたアクセストークンを取得します。JSONをリストへ変換したもので返します。"
  (gcal-retrieve-json-post-www-form
   gcal-token-url
   `(
     ("client_id" ,client-id)
     ("client_secret" ,client-secret)
     ("redirect_uri" "urn:ietf:wg:oauth:2.0:oob")
     ("grant_type" "refresh_token")
     ("refresh_token" ,refresh-token))))

Google Calendar APIを実際に使ってみる

認証

上で作成した関数を使用してGoogle Calendar API用の認証を行います。

(defcustom gcal-client-id "xxxxxxx.apps.googleusercontent.com" "client-id for Google Calendar API")
(defcustom gcal-client-secret "XxxClieNtSeCretXx" "client-secret for Google Calendar API")

(defconst gcal-auth-url "https://accounts.google.com/o/oauth2/auth")
(defconst gcal-token-url "https://www.googleapis.com/oauth2/v3/token")
(defconst gcal-scope-url "https://www.googleapis.com/auth/calendar")

(defvar gcal-access-token nil)

(defun gcal-access-token ()
  (setq gcal-access-token
        (gcal-oauth-get
         gcal-access-token
         gcal-auth-url gcal-token-url
         gcal-client-id gcal-client-secret gcal-scope-url))
  (gcal-oauth-token-access gcal-access-token))

gcal-client-idgcal-client-secret は最初にGoogle Developer Consoleで取得した情報を設定する必要があります。

(gcal-access-token)でアクセストークンが手に入ります。 初回はブラウザが開くので、そのページでアクセスを承認して認証コードを表示させ、そのコードをEmacsのミニバッファへ入力する必要があります。

カレンダー一覧

さて、実際にカレンダーの情報にアクセスしてみましょう。

まずは認証したアカウントが持つカレンダーの一覧を取得する方法。

(gcal-retrieve-json-get
 "https://www.googleapis.com/calendar/v3/users/me/calendarList"
 `(
   ("access_token" ,(gcal-access-token))
   ;;("key" ,gcal-client-secret)
   ;;("grant_type" "authorization_code")
   ))

次のような結果が返ってきます。

(
 (kind . "calendar#calendarList")
 (etag . "\"1234567890123456\"")
 (nextSyncToken . "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=")
 (items . [
           (
            (kind . "calendar#calendarListEntry")
            (etag . "\"1234567890123456\"")
            (id . "example@gmail.com")
            (summary . "example@gmail.com")
            (timeZone . "Asia/Tokyo")
            (colorId . "2")
            (backgroundColor . "#d06b64")
            (foregroundColor . "#000000")
            (selected . t)
            (accessRole . "owner")
            (defaultReminders . [((method . "popup") (minutes . 30)) ((method . "sms") (minutes . 30))])
            (notificationSettings (notifications . [((type . "eventCreation") (method . "email")) ((type . "eventChange") (method . "email")) ((type . "eventCancellation") (method . "email"))]))
            (primary . t)
           )
           (
            (kind . "calendar#calendarListEntry")
            (etag . "\"1234567890123456\"")
            (id . "ja.japanese#holiday@group.v.calendar.google.com")
            (summary . "日本の祝日")
            (description . "日本の祝日と行事")
            (timeZone . "Asia/Tokyo")
            (colorId . "7")
            (backgroundColor . "#42d692")
            (foregroundColor . "#000000")
            (selected . t)
            (accessRole . "reader")
            (defaultReminders . [])
           )
          ])
 )

追加で指定できるパラメータや得られる結果等についてはAPIリファレンス(CalendarList: list)を参照してください。

イベント(予定)のリスト

カレンダーID example@gmail.com のイベントを取得するには次のようにします。

(gcal-retrieve-json-get
 "https://www.googleapis.com/calendar/v3/calendars/example@gmail.com/events"
 `(
   ("access_token" ,(gcal-access-token))
   ("key" ,gcal-client-secret)
   ("grant_type" "authorization_code")
   ))

追加で指定できるパラメータや得られる結果等についてはAPIリファレンス(Events: list)を参照してください。

イベント(予定)の追加

(gcal-retrieve-json-post-json
 "https://www.googleapis.com/calendar/v3/calendars/example@gmail.com/events"
 `(
   ("access_token" ,(gcal-access-token))
   ("key" ,gcal-client-secret)
   ("grant_type" "authorization_code")
   )
 `(
   ("start"  ("date" "2016-05-25") )
   ("end"  ("date" "2016-05-26"))
   ("summary" "テストの予定2")
   )
 )

APIリファレンス(Events: lisert)

バッチ

多量のリクエストを送る(50まで)にはmultipartなHTTPリクエストを送るんだそうです。

url-retrieveはmultipartはできないみたいなのでrequest.elを使った方が良いかもしれません(?)

ソースはGitHubで

上のコードはgcal.elとして、また、org-modeのアクティブタイムスタンプをイベントとしてアップするコードをgcal-org.elとしてGitHub上に置きました。

https://github.com/misohena/gcal

2016-05-11

プリンターが欲しい

プリンターが欲しいと前々から思っているのだけど、置く場所を考えるととても買う気にならない自分がいる。

印刷が必要になる頻度は多くはないのだが、最近色々な手続きで印刷が必要なことが重なった。必要になるたびにコンビニに行ってネットプリントやらコピーやらしていたのだけど、こう度々だと嫌になってくる。とはいえ、その必要性はそろそろ一段落してきた。しかし以前からプリンターは欲しいと思っていたし、この機会にまた検討してみた。

とにかくコンパクトであることを優先するとモバイルプリンターが気になる。しかしこの手のものは性能はどうなのだろうか。あまり印刷品質が悪いと使う気が失せてコンビニでいいやということになりかねない。印刷速度やインクの持ちも気になる。あと、値段は2万円前後と少し高い。これで失敗するとガッカリ感が大きい。

持ち運ぶわけではないので、ここまでのコンパクトさが必要なのかは疑問だ。場所さえ確保できるならば、もう少し別の選択肢があるのではないか。

ちなみに、Amazonでインクジェットプリンターのベストセラー1位はCanon PIXUS iP2700。そのお値段何と2995円である。ひぇー。

ここまで来るとプリンターもボールペンのようにインクを替えずに使い捨てるものなのかと思う。とはいえボールペンのように机の引き出しにジャラジャラ入れておく訳にもいくまい。それでも幅が少し大きい(44.5cm)けれど、奥行き(25cm)と高さ(13cm)はそこそこ小さく収まっている。処分するときは不燃ゴミの袋に入るだろうか。いや、家の市では40cm以上は粗大ゴミと書いてあった。残念。それと印刷するときはトップを開くのでもう少し大きなスペースが必要になるようだ。

そもそもカラー印刷は必要なのだろうか。必要なければレーザープリンタも考慮の内に入ってくる。インクジェットでも顔料なら文字の品質は悪くないようだが、それでもレーザープリンタの品質、速度、インク詰まりのなさは魅力的だ。本当にカジュアルに印刷できる。ちなみにカラーレーザーはまだまだ大きすぎるので不可。

レーザープリンタ部門でAmazonベストセラー1位は brother JUSTIO HL-L2365DW 。なかなか良さそうだ。寸法は356×360×183mm。うーん、高さがもう少し低ければスチールラックに入るのだけど。スチールラックの棚板を調整して場所を確保できれば良いのだけど。

サイズだけで選ぶならば、インクジェットの複合型だけれど EPSON PX-048A あたりは良い。高さ145mmならスチールラックに入る。スキャナを開くのは難しいかもしれないけれど。いや、ギリギリ開いて紙を差し込むくらいは出来るだろうか。 これに対応する他メーカーだと、brother PRIVIO DCP-J963N-WHP ENVY4504などだろうか。

と、色々調べてみたが正直面倒くさい。どれも一長一短だ。ネットでぽちぽち見ている分には場所も取らないが、いざ到着して開封してみればその大きさにうんざりするのが常。それで滅多に使わないときている。

部屋を片付けて、もしスペースが空いたときはご褒美として買っても良いかもしれない。

2016-04-26 ,

日本語grep(lvgrep)

日本語のgrepってみんなどうやってるんだろう。メジャーなディストリビューションとか、日本語でgrepが出来ないとは思えないけど。あ、今はもうUTF-8統一なのか。

Emacs25でdiredのAとQがdired-do-find-系(dired-do-find-regexpとdired-do-find-regexp-and-replace)に変わってしまったのでさあ大変。 これまでのdired-do-searchとdired-do-query-replace-regexpは外部コマンド(find,grep)に依存していないのでWindows上でも安定して使えたのだけど、dired-do-find-系はそれらに依存しているのでトラブルに。 dired-do-searchとdired-do-query-replace-regexpはまだ使えるのでキーを割り当て直せば良いんだろうけど、dired-do-find-系はディレクトリを指定できるメリットがあるのでこれを機に使えるようにした。

まずはfind。そのままだと環境変数PATHの優先順位の関係でWindowsのfindコマンドが使われてしまう。 私の場合、シェル(shell-file-name)はcmdproxy.exeではなくてCygwinのshにしてあるので、find-programに /bin/find と絶対パスを指定して解決。

次はgrep。日本語のgrepは前世紀からいろんな人が苦労していた気がするけど、いまさらまたこの設定をいじるなんて。 日本語(正確には日本語でよく使われる文字符号化方式でエンコードされたテキスト)のgrepの方法としては次のようなものが思い当たる。

  • lgrepを使う
  • nkfを噛ます
  • iconvを噛ます
  • 日本語に対応する改造を施したgrepを使う

私はlgrepを使ってきた。lgrepはlvの検索機能部分で、Cygwinのパッケージになっていて簡単にインストールできる。lv&lgrepの文字変換機能は素晴らしいけれど、最大の欠点はGNUのgrepとオプションに互換性がないことだ。特に-Hでファイル名が表示できないのが痛い。代わりになるオプションもない。

nkfはパッケージになってないからビルドするのが面倒くさいし(lvも昔はパッケージになっていなかったので自分でビルドしてた)、日本語対応grepをビルドするのも同じ。iconvは自動判別がそのままでは難しい。

結局やりたいことは、文字コードを自動的判別して変換して、それに対してgrepをすることだから、次のようなスクリプトを作成した。

#!/bin/sh
export LANG=ja_JP.CP932
lv -Os ${@:$#} | grep --label=${@:$#} ${@:1:$#-1}

LANGが無いとCygwinのgrepはUTF-8前提になってしまうのでLANGを指定した。私はCP932前提のコマンドライン用プログラムを多数抱えているのでWindowsでUTF-8に移行する気にはまだなれない。

lvには-Osだけを指定して、入力は自動判別にし出力はShift_JIS(≒CP932)とする。

入力ファイルの指定はスクリプトの最後の引数とする。それをlvとgrepの–label=オプションへ渡す。その他の引数はgrepにそのまま渡すことにした。

これをlvgrepとして保存して、Emacsではgrep-program、grep-command、find-grep-optionsあたりでこれを指定するようにした。

とりあえずうまく行っているみたい?

dired-do-searchと比べて外部のgrepを使っているせいで空白の検索が素直に指定できない等不便なことはあるけど仕方なし。Emacsの正規表現と一貫性を持たせることを考えると、あまり外部コマンドに依存するのは考え物だと思うんだけどなぁ。

(2019-01-15追記)

grepのオプションにできるだけ対応した。複数ファイル処理可能。ただしディレクトリを指定する機能は対応していない。

#!/bin/bash
export LANG=ja_JP.CP932

# option alias map
declare -A OPT_ALIASES;
OPT_ALIASES["--regexp"]="-e";
OPT_ALIASES["--file"]="-f";
OPT_ALIASES["--ignore-case"]="-i";
OPT_ALIASES["--invert-match"]="-v";
OPT_ALIASES["--word-regexp"]="-w";
OPT_ALIASES["--line-regexp"]="-x";
OPT_ALIASES["--count"]="-c";
OPT_ALIASES["--colour"]="--color";
OPT_ALIASES["--files-without-match"]="-L";
OPT_ALIASES["--files-with-matches"]="-l";
OPT_ALIASES["--max-count"]="-m";
OPT_ALIASES["--only-matching"]="-o";
OPT_ALIASES["--quit"]="-q";
OPT_ALIASES["--silent"]="-q";
OPT_ALIASES["--no-messages"]="-s";
OPT_ALIASES["--byte-offset"]="-b";
OPT_ALIASES["--with-filename"]="-H";
OPT_ALIASES["--no-filename"]="-h";
OPT_ALIASES["--line-number"]="-n";
OPT_ALIASES["--initial-tab"]="-T";
OPT_ALIASES["--unix-byte-offsets"]="-u";
OPT_ALIASES["--null"]="-Z";
OPT_ALIASES["--after-context"]="-A";
OPT_ALIASES["--before-context"]="-B";
OPT_ALIASES["--context"]="-C";
OPT_ALIASES["--text"]="-a";
OPT_ALIASES["--devices"]="-D";
OPT_ALIASES["--directories"]="-d";
OPT_ALIASES["--recursive"]="-r";
OPT_ALIASES["--dereference-recursive"]="-R";
OPT_ALIASES["--binary"]="-U";
OPT_ALIASES["--null-data"]="-z";

# parse command line
declare -a files=()
declare -a opt_arr=()
declare -A opt_hash
declare unresolved_arg=""

function push_opt () {
    if [[ -v OPT_ALIASES[$1] ]]; then
        opt_hash[${OPT_ALIASES[$1]}]=$2;
    else
        opt_hash[$1]=$2;
    fi
}

for arg in "$@"; do
    # \ -> \\
    qarg="${arg//\\/\\\\}"
    # " -> \"
    qarg="${qarg//\"/\\\"}"

    if [[ -n $unresolved_arg ]]; then # -prev curr
        options+=($unresolved_arg);
        options+=("$qarg");
        push_opt $unresolved_arg "$qarg";
        unresolved_arg="";
    elif [[ ${arg} =~ ^(--[^=]+)=(.*)$ ]]; then # --???=???
        options+=("$qarg");
        push_opt ${BASH_REMATCH[1]} "${BASH_REMATCH[2]}";
    elif [[ $arg =~ ^-- ]]; then # --???
        options+=("$qarg");
        push_opt $arg "";
    elif [[ $arg =~ ^-[efmABCDd]$ ]]; then # -curr next
        unresolved_arg=$arg;
    elif [[ $arg =~ ^-[^-] ]]; then # -???
        options+=("$qarg");
        i=1;
        while [[ $i -lt ${#arg} ]]; do
            push_opt -${arg:(i++):1} "";
        done;
    elif [[ ! -v opt_hash["-e"] ]]; then # PATTERN
        options+=("$qarg");
        push_opt "-e" "$qarg";
    else # FILE
        files+=("$qarg");
    fi
done

# echo files="${files[@]}" >> ~/tmp/lvgrep.log
# echo options="${options[@]}" >> ~/tmp/lvgrep.log
# for x in "${!opt_hash[@]}"; do printf "%s = %s\n" "$x" "${opt_hash[$x]}" >> ~/tmp/lvgrep.log; done

# -h or -H
if [[ -v opt_hash["-h"] || -v opt_hash["-H"] ]]; then
    :
elif [ ${#files[@]} -lt 2 ]; then
    options+=("--no-filename");
else
    options+=("--with-filename");
fi

# execute each file
found=false
for file in "${files[@]}"; do
    cmd="lv -a -Os \"${file}\" | grep --label=\"${file}\" ${options[@]@Q}"
    # echo "$cmd" >> ~/tmp/lvgrep.log
    if bash -c "$cmd"; then
        found=true
    fi
done

# return exit status code
if [ "$found" = true ]; then
    exit 0;
else
    exit 1;
fi;

(2016-12-21追記)

複数のファイルを指定したときに正しく動作するようにした。

#!/bin/bash
export LANG=ja_JP.CP932

# parse command line
files=()
pattern=""
options=()
for arg in "$@"; do
    # \ -> \\
    qarg="${arg//\\/\\\\}"
    # " -> \"
    qarg="${qarg//\"/\\\"}"
    if [[ ${arg} = -* ]]; then
        options+=(${qarg});
    elif [[ -z "${pattern// }" ]]; then
        pattern=${qarg};
    else
        files+=(${qarg});
    fi
done
#echo pattern=${pattern}
#echo files="${files[@]}"
#echo options="${options[@]}"

# -h or -H
if echo " ${options[@]} " | grep -e " \\(--no-filename\\|--with-filename\\|-[^ -]*[hH][^ -]*\\) "; then
    :
elif [ ${#files[@]} -lt 2 ]; then
    options+=("--no-filename");
else
    options+=("--with-filename");
fi

# execute each file
found=false
for file in "${files[@]}"; do
    c="lv -Os \"${file}\" | grep --label=\"${file}\" ${options[@]} \"${pattern}\""
    #echo "$c"
    if bash -c "$c"; then
        found=true
    fi
done

# return exit status code
if [ "$found" = true ]; then
    exit 0;
else
    exit 1;
fi;
2016-04-23

Emacs25(MinGW64)+cygwin-mount.elでtramp経由でリモートファイルを開けない

diredでリモートのディレクトリリストは表示されてもファイルを開こうとすると次のようなエラーが。

Tramp: Encoding remote file ‘/scpx:user@example.jp:/home/user/tmp/test1.txt’ with ‘openssl enc -base64 <%s’...done
Tramp: Decoding local file ‘c:/app/cygwin/tmp/tramp.6920y_V.txt’ with ‘base64-decode-region’...done
Tramp: Inserting ‘/scpx:user@example.jp:/home/user/tmp/test1.txt’...failed
Removing old name: No such file or directory, /app/cygwin/app/cygwin/tmp/tramp.6920y_V.txt

調べていくとカレントディレクトリ(default-directory)によって結果が変わった。 scratchバッファからリモートファイルをfind-fileすると問題なく開いた。 tramp上のパスをdiredで開いて、そこから開こうとするとエラーになった。

  1. find-file
  2. insert-file-contents
  3. tramp-handle-insert-file-contents
  4. tramp-sh-handle-file-local-copy
  5. with-temp-file
  6. write-region

と追っていくと、次のようなコードでエラーが再現。

(let ((default-directory "/scpx:user@example.jp:/home/user/tmp"))
  (write-region nil nil "c:/app/cygwin/tmp/a.txt"))

default-directoryがローカルなら何事もなく成功する。

write-regionはCで書かれているので見てみると、中でexpand-file-nameを使ってファイル名を展開し、そのファイルに対して出力している。

絶対パスにしているならカレントディレクトリによって動作が変わるはずはない。

エラーメッセージとして/app/cygwin/app/cygwin/tmp/tramp.6920y_V.txtのような全くおかしなパスが表示されているので、念のためexpand-file-nameを調べたところ、おかしな挙動に気がついた。

  • (expand-file-name "c:/a.txt" "c:/")c:/a.txt
  • (expand-file-name "c:/a.txt" "/ssh:/")/a.txt

あれれ?

expand-file-nameもCで書かれていたので中身を見てみると、default-directoryを元にfind-file-name-handlerして実際にexpand-file-nameを処理する関数を求めていた。

あー、ローカルとリモートでファイルシステムが違うんだ。default-directoryがtrampパスの時tramp-sh-handle-expand-file-nameが使われるんだ。 で、tramp-sh-handle-expand-file-nameはWindowsのことをこれっぽっちも考慮していないからc:/aが/aになってしまうのね。

C:/app/cygwin/tmp/a.txt が tramp-sh-handle-expand-file-name によって /app/cygwin/tmp/a.txt になり、これがcygwin-mount.elによってcygwinルートディレクトリを基準にしたパスとして展開され、/app/cygwin/app/cygwin/tmp/a.txtになるわけだ。ヤレヤレ。

というか、そもそもexpand-file-nameにローカルの絶対パスを渡したときにtrampのexpand-file-nameが使われるってどうなのよ。 ここは展開対象パスがローカルの絶対パスなのだから、default-directoryに関わらずローカルのハンドラによってexpandするべきなんじゃないのか? というわけで、expand-file-nameにadvice-addしたのだけど、何故かうまく行かなかった。Cから呼んでいるからだろうか。

仕方ないのでtramp-sh-handle-expand-file-name側にadvice-addしてc:/がc:/のまま/にならないようにしたところ、リモートのファイルがtramp経由で開けるようになった。

(advice-add
 'tramp-sh-handle-expand-file-name :around
 (lambda (orig-func name &optional dir)
   (if (save-match-data (string-match "^[a-z]+:[/\\]" name))
       (expand-file-name name (file-name-directory name))
     (apply orig-func (list name dir)))))

ちなみに、リモート・ローカル間でコピーできない現象は次のページの tramp-do-copy-or-rename-file-out-of-band へのadviceで治った。

NTEmacs @ ウィキ - tramp を tramp-method “scp” で使うための設定 - @ウィキモバイル

最後に、trampのコードを追うためにはファイル名ハンドラのことを知っていないといけないのでしっかり押さえておくこと。

2016-04-22 ,

Windows10でウィンドウのアクティブタイトルバーに色をつける

Vistaからだったろうか、Win7からだったろうか。アクティブタイトルバーに色が付かなくなったのは。主にキーボードで操作している人間にとって、今どこにキーフォーカスが当たっているのかは非常に重要な情報だ。特に最近のディスプレイは大きい。23インチ、マルチディスプレイ環境で沢山のウィンドウがあると、一目で全てのウィンドウに目を通すのは難しい。ましてやわずかな明暗の差しか無いアクティブウィンドウを識別するのは容易ではない。

そんな状況が続きつつも最近は諦めていたのだが、ひょんな事から何か状況が変わっていないかと「windows10 active titlebar」で検索したら「タイトルバーに色づけ」なんて記事が大量に出てくるではないか!

どうもWindows10のThreshold2から(Insider Previewではその前から)出来るようになっていたらしい。

最近の私の情報感度の低さというか、情弱っぷりには我ながらびっくりするくらいだ。

などと記事を辿っていくと、Aero Liteなるテーマがあったようだ。

何はともあれ、あるべき設定があるべき場所にあるのは良いことだ。