Monthly Archives: 11月 2024

2024-11-30

Diredに好きなファイル情報を追加する

昨日の続き。

前回はファイルのスコアが書かれているCSVファイルを元にDired内のファイルをソートしてみましたが、これだけだとパッと見何の順番で並んでいるのかよく分かりませんよね。やはりファイルの一覧の中にスコアの数字そのものも表示されていないとピンときません。なので今回はDiredのファイル行の中に、ファイルに関連した任意の情報を追加する方法について考えてみようと思います。

勝手にファイル情報を追加しても大丈夫なのか

前回も書いたとおりDiredは単なるテキストバッファですから、勝手に書き替えてしまえば済む話です。本当に? 結論としては、たぶん、概ね問題ないと思います。

Diredはファイルに関する情報を変数の形では保持しておらず、全てバッファ内のテキストとして保持しています。なのでファイル名を始め全ての情報は何かコマンドを実行するたびにバッファ内のテキストから毎回抽出しなければなりません。デフォルトの -al オプションでファイルリストを作成すると、ファイル行には左から順にファイルの種類(d)、モードビット、ハードリンク数、所有者名、グループ名、サイズ、タイムスタンプ、そしてファイル名が並びます(シンボリックリンクの場合はその後に -> で始まる部分が追加されます)。この中で一番重要なのは当然一番右のファイル名ですが、その位置を正確に割り出すのは案外面倒な問題が伴います。そんな中で、ファイル名の左側に好きな情報を勝手に追加してしまっても大丈夫なのでしょうか。

大丈夫なのです。ファイル名の左にどんなテキストを追加してもDiredは正確にファイル名の位置を割り出すことが出来ます。

insert-directoryの秘密

その秘密はinsert-directoryという関数にあります。この関数はlsの出力をバッファに挿入する関数です(lsが無い環境ではls-lisp.elでエミュレートします)。lsと同じようにファイルパスとスイッチを引数に取ります。今回注目すべきは、スイッチとして --dired を指定したときの挙動です。GNUのlsには --dired (または-D)というオプションがあり、これが指定されているとlsは出力の最後にファイル名部分の範囲を先頭からの文字数で列挙してくれます。例えば次のように。

> ls -al --dired 
  total 684
  drwxr-xr-x 1 user user     0 11月 27 18:36 ./
  drwxr-xr-x 1 user user     0 11月 30 15:31 ../
  -rw-r--r-- 1 user user  1948  9月 25 16:55 compface.el
  -rw-r--r-- 1 user user   906 10月 26 18:46 compface.elc
  -rw-r--r-- 1 user user 12701  9月 25 16:55 exif.el
  -rw-r--r-- 1 user user  7640 10月 26 18:46 exif.elc
  -rw-r--r-- 1 user user 13135  9月 25 16:55 gravatar.el
  -rw-r--r-- 1 user user  9698 10月 26 18:46 gravatar.elc
  -rw-r--r-- 1 user user 14269  9月 25 16:55 image-converter.el
  -rw-r--r-- 1 user user 10276 10月 26 18:46 image-converter.elc
  -rw-r--r-- 1 user user 17854  9月 25 16:55 image-crop.el
  -rw-r--r-- 1 user user 13474 10月 26 18:46 image-crop.elc
  -rw-r--r-- 1 user user 84378 10月 16 04:24 image-dired.el
  -rw-r--r-- 1 user user 83477 10月 26 18:46 image-dired.elc
  -rw-r--r-- 1 user user 17044  9月 25 16:55 image-dired-dired.el
  -rw-r--r-- 1 user user 18328 10月 26 18:46 image-dired-dired.elc
  -rw-r--r-- 1 user user 29509  9月 25 16:55 image-dired-external.el
  -rw-r--r-- 1 user user 21633 10月 26 18:46 image-dired-external.elc
  -rw-r--r-- 1 user user 13209  9月 25 16:55 image-dired-tags.el
  -rw-r--r-- 1 user user  9952 10月 26 18:46 image-dired-tags.elc
  -rw-r--r-- 1 user user  8924  9月 25 16:55 image-dired-util.el
  -rw-r--r-- 1 user user  7195 10月 26 18:46 image-dired-util.elc
  -rw-r--r-- 1 user user 23171  9月 25 16:55 wallpaper.el
  -rw-r--r-- 1 user user 24016 10月 26 18:46 wallpaper.elc
//DIRED// 59 60 109 111 160 171 219 231 279 286 334 342 390 401 449 461 509 527 575 594 642 655 703 717 765 779 827 842 890 910 958 979 1027 1050 1098 1122 1170 1189 1237 1257 1305 1324 1372 1392 1440 1452 1500 1513
//DIRED-OPTIONS// --quoting-style=literal

末尾の //DIRED// で始まる行の後に並ぶ数字はファイル名部分の範囲を示しています。最初は59ですが、これは最初のファイル名の先頭が出力の先頭から数えて59文字目にあることを意味しています。次の60はファイル名の末尾の位置を同様に表します。つまり、一番最初の . を指しています。以下同様に続きます。

そして重要なのは、insert-directoryはこの情報を読み取ってバッファ内のファイル名が書かれている範囲にテキストプロパティ dired-filename=t を設定するということです。

なので行の中にあるファイル名を表す範囲を特定するにはテキストプロパティが設定されている範囲を探すだけで良く、その前にどんなテキストがあってもそのテキストプロパティが存在する限り(少なくともファイル名を特定するには)問題ありません。そのテキストプロパティが設定されている場所を探す関数がdired-move-to-filenamedired-move-to-end-of-filenameです。その中を見れば行末までの間にある dired-filename という名前のテキストプロパティを探していることが分かります。(注:これらの関数を見るとテキストプロパティが検出できなかったときの処理も書かれていて、正規表現検索によってファイル位置の範囲を特定しようとしています。なので100%問題ないとは言い切れない部分がありますが、今回はその辺りは無視することにします)

実際に追加してみる

前回書いたコードの中から流用できるものを引っ張ってきましょう。

サンプルのCSV。

muscat.html,230
melon.html,140
grape.html,185
cherry.html,210
strawberry.html,153

これを読む関数。

(defun my-dired-sort-read-file-score (dir)
  (ignore-errors
    (with-temp-buffer
      (insert-file-contents (file-name-concat dir ".file-score.csv"))
      (goto-char (point-min))
      (cl-loop until (eobp)
               for (file score) = (split-string (buffer-substring (point) (line-end-position)) ",")
               collect (cons (expand-file-name file dir)
                             (string-to-number score))
               do (forward-line)))))

一つのディレクトリの中のファイルリストの範囲を特定する関数。

(defun my-dired-next-dir-files-region ()
  ;; ファイル名が無いところをスキップ
  (while (and (null (dired-move-to-filename))
              (= (forward-line) 0)))
  (unless (eobp)
    (let ((beg (line-beginning-position)))
      ;; ファイル名があるところをスキップ
      (while (and (dired-move-to-filename)
                  (= (forward-line) 0)))
      (cons beg (line-beginning-position)))))

それでファイルリストの中にスコアの数字を挿入する関数を作成します。

(defun my-dired-add-file-score-to-region (beg end)
  (when-let ((file-score-alist
              (my-dired-sort-read-file-score (dired-current-directory))))
    (goto-char beg)
    (while (< (point) end)
      ;; 各行について
      (when (dired-move-to-filename) ;; ファイル名の先頭へ移動
        ;; ファイル名とスコアを取得
        (let* ((file (dired-get-filename nil t))
               (score (alist-get file file-score-alist nil nil #'string=)))
          ;; 1文字左へ移動
          (backward-char)
          ;; スコアを挿入
          (insert (if score (format " %3d" score) "    "))))
      ;; 次の行へ
      (forward-line))))

バッファ内にある全ディレクトリに対してこれを適用する関数を作成します。前回作った my-dired-sort-by-file-score 関数とほとんど同じですが、今回は挿入すると末尾の位置がずれていくためマーカーを使用しています。そして my-dired-sort-region-by-file-score の代わりに今作った my-dired-add-file-score-to-region を呼び出すようにすればOKです。

(defun my-dired-add-file-score ()
  (interactive nil dired-mode)
  (widen)
  (goto-char (point-min))
  (let ((inhibit-read-only t))
    (while-let ((files-region (my-dired-next-dir-files-region)))
      (let ((beg (car files-region))
            (end (copy-marker (cdr files-region))))
        (my-dired-add-file-score-to-region beg end)
        (goto-char end)
        (set-marker end nil)))))

前回作ったソートコマンドと一緒に使ってみるとDiredバッファは次のような見た目になりました。

c:/home/user/tmp/my-dired:
drwxr-xr-x 1 user user  0 11月 30 23:14     .
drwxr-xr-x 1 user user  0 11月 30 23:13     ..
-rw-r--r-- 1 user user 87 11月 30 23:14     .file-score.csv
-rw-r--r-- 1 user user  7 11月 30 23:13 140 melon.html
-rw-r--r-- 1 user user 12 11月 30 23:14 153 strawberry.html
-rw-r--r-- 1 user user  7 11月 30 23:13 185 grape.html
-rw-r--r-- 1 user user  8 11月 30 23:13 210 cherry.html
-rw-r--r-- 1 user user  8 11月 30 23:13 230 muscat.html

スコアが表示され、その順番でファイルが並んでいることが分かります。

2024-11-29 ,

Diredでファイルを好きな順に並べる

Web検索すると s キーや C-u s でソート順を変更できるという情報が見つかりますがそういった話ではありません。悪しからず。

Diredではlsやls-lispが認識するオプションによってファイルをソートできますが、ソートのキーに出来るのはあくまでファイルシステムが認識できるもののみです。ファイルシステムの外にある何らかの情報に基づいてファイルをソートするにはどうしたら良いでしょうか。

テキストバッファレベルでのソート

基本的にDiredというのは(良くも悪くも)ファイル情報を並べただけのテキストバッファに過ぎないので、read-onlyを一時的に解除して好きなように並べ替えてしまえば良いのです。ただし、次のことに注意する必要があります。

  • C-x C-q (dired-toggle-read-only)はwdiredを起動してしまうので使えません。 M-x read-only-mode するか、Emacs Lispから (let ((inhibit-read-only t)) ...書き替え... ) しましょう。
  • ファイル部分には見えないテキストプロパティが設定されているのでそれが失われないようにしましょう。
  • ディレクトリの範囲はdired-subdir-alist変数とマーカーで管理されているので、それが壊れないようにしましょう。
  • Diredの一部の機能や拡張はファイル部分にオーバーレイを設定するので、それに配慮しましょう。いったん解除してから編集するか、または、それらが機能する前に割り込んで処理してしまうかです。

試しに M-x read-only-mode の後にファイルの範囲を正確にリージョンで囲って、 M-x sort-lines してみればソートされることが確認できると思います。

外部の情報を元にテキストバッファをソート

例えば何かファイルに対するスコアを列挙したファイル(.file-score.csv)があるとしましょう。

muscat.html,230
melon.html,140
grape.html,185
cherry.html,210
strawberry.html,153

これを読み込むには次のようなEmacs Lispの関数を作れば良いでしょう。

(defun my-dired-sort-read-file-score (dir)
  (ignore-errors
    (with-temp-buffer
      (insert-file-contents (file-name-concat dir ".file-score.csv"))
      (goto-char (point-min))
      (cl-loop until (eobp)
               for (file score) = (split-string (buffer-substring (point) (line-end-position)) ",")
               collect (cons (expand-file-name file dir)
                             (string-to-number score))
               do (forward-line)))))

試しに実行してみると次のようになります。

(my-dired-sort-read-file-score "/products/")
(("/products/muscat.html" . 230)
 ("/products/melon.html" . 140)
 ("/products/grape.html" . 185)
 ("/products/cherry.html" . 210)
 ("/products/strawberry.html" . 153))

これを元にDired上でファイルをソートするには、sort-subr関数を使用すると次のように書けます。

(defun my-dired-sort-region-by-file-score (beg end)
  (when-let ((file-score-alist
              (my-dired-sort-read-file-score (dired-current-directory))))
    (save-restriction
      (narrow-to-region beg end)
      (goto-char beg)
      (sort-subr
       nil            ; REVERSE
       #'forward-line ; NEXTRECFUN
       #'end-of-line  ; ENDRECFUN
       ;; STARTKEYFUN (注意:決してnilを返さないこと)
       (lambda ()
         (alist-get (dired-get-filename nil t) file-score-alist 0 nil #'string=))
       nil            ; ENDKEYFUN
       #'<            ; PREDICATE
       ))))

sort-subrはバッファ内のテキストをソートする汎用的な関数です(Sorting (GNU Emacs Lisp Reference Manual))。引数STARTKEYFUNには通常キーとなるテキストの先頭へ移動する関数を指定しますが、単にキーとなる値を返しても構いません。ここではファイル名に対応するスコアの数値を返しています。キーを数値にしたのでPREDICATEには #'< を指定していますが、デフォルトのままでも大丈夫かもしれません。

試しにテスト用のファイルを用意して、Diredバッファ内でファイルをリージョンで囲い、 M-: (my-dired-sort-region-by-file-score (region-beginning) (region-end)) などとしてみるとちゃんとソートされることが分かります。

一つのディレクトリのファイルリストの範囲

ここまでソートする範囲を手動で指定していましたが、ディレクトリの先頭のファイルと最後のファイルの位置を自動的に求めるにはどうしたら良いでしょうか。

ここで注意する必要があるのが、Diredは一つのバッファ内に複数のディレクトリを表示できるという点です(dired-insert-subdir等)。なのでその一つ一つのディレクトリ毎にファイルリストの範囲を求める必要があります。

最も単純なのはバッファ内の全行をスキャンしてファイルがある所とファイルが無いところを判別していくことです。

(defun my-dired-next-dir-files-region ()
  ;; ファイル名が無いところをスキップ
  (while (and (null (dired-move-to-filename))
              (= (forward-line) 0)))
  (unless (eobp)
    (let ((beg (line-beginning-position)))
      ;; ファイル名があるところをスキップ
      (while (and (dired-move-to-filename)
                  (= (forward-line) 0)))
      (cons beg (line-beginning-position)))))

行にファイル情報があるかどうかはdired-move-to-filenameが非nilを返すかどうかで判別できます。この方法はdired.el内で広く使われています。

そしてファイル情報が連続している範囲を見つけて返すのが上のコードになります。

全行をスキャンするのが嫌ならばdired-subdir-alistを利用することもできます。

(defun my-dired-next-dir-files-region ()
  ;; ファイル名が無いところをスキップ
  (while (and (null (dired-move-to-filename))
              (= (forward-line) 0)))
  (unless (eobp)
    (let ((beg (line-beginning-position)))
      ;; 次のディレクトリの先頭またはEOBへジャンプ
      (goto-char (dired-subdir-max))
      ;; ファイルがある所まで戻る
      (while (and (= (forward-line -1) 0)
                  (not (dired-move-to-filename))))
      ;; その次の行の先頭
      (forward-line)
      (cons beg (point)))))

dired-subdir-max関数は現在のポイントが指し示すディレクトリのバッファ内での末尾位置を返します。dired-subdir-alistには各ディレクトリの末尾の位置がマーカーとして保存されているので、全行をスキャンせずとも高速に割り出すことが出来ます。

ただ、その位置は正確に最後のファイルを指し示していないので、若干の後戻りスキャンが必要になります。

Diredバッファ内の全てのファイルをソートする

以上を組み合わせると次のようなコマンドを仕立てることが出来ます。

(defun my-dired-sort-by-file-score ()
  (interactive nil dired-mode)
  (widen)
  (goto-char (point-min))
  (let ((inhibit-read-only t))
    (while-let ((files-region (my-dired-next-dir-files-region)))
      (my-dired-sort-region-by-file-score (car files-region) (cdr files-region))
      (goto-char (cdr files-region)))))

どのタイミングでソートするか

手動でコマンドを実行したときだけソートされれば良いのであればこれで十分です。しかし何かのコマンドを実行したら常にソートされていて欲しい場合はどうしたら良いでしょうか。

当然Diredバッファが更新されるたびにどこかで自動的にソートする必要があります。

Diredのバッファ更新処理は大ざっぱに言って次のような流れを辿ります。

dired-noselect等
  → dired-revert
     → dired-readin (あるいはdired-insert-old-subdirs)
        → dired-readin-insert
            → dired-insert-directory
                → insert-directory
                    → file-name-handler(insert-directory) or
                       ls-lisp--insert-directory or
                       call-process(sh -c ls)
     → run-hook 'dired-after-readin-hook

最も無難なのがdired-after-readin-hookのタイミングです。このタイミングではバッファの更新が一通り終わって、上のコードで使用している全てのdired関数(dired-move-to-filenamedired-subdir-maxdired-get-filename等)が正常に機能します。ただし、他の拡張もこのタイミングで様々な処理を行うことが多いため、競合には注意した方が良いでしょう。

他のタイミングとしては、一つのディレクトリを挿入するdired-insert-directoryinsert-directoryの直後が考えられるでしょう。ただし、このタイミングでソートをするにはいくつか注意が必要です。

  • 全てのdired関数が使用できない、あるいは正しく動作しないケースがある可能性がある。例えばdired-insert-directoryの直後ではdired-subdir-alistが構築されていないため、ディレクトリのファイルリストの範囲は全行スキャンしなければなりません。dired-move-to-filenameはおそらく大丈夫ですが、dired-get-filenameは問題がありそうです。
  • ファイル名のエスケープ処理に注意。バッファに挿入されるファイル名にはある種のエスケープ処理が施されています。なので、バッファからファイル名を取得するときにはエスケープを解除する処理が必要になります。dired-get-filenameはその辺りの処理をちゃんと行ってくれます。

ls-lispの中でソートするのは魅力的なアイデアでしょう。ls-lispはlsコマンドを使用せずEmacsの機能だけでlsをエミュレートするライブラリです。Windowsや最近ではAndroidでもデフォルトではls-lispが使われるようになっています。ls-lispは内部でdirectory-files-and-attributes関数を呼び出してファイルリストを取得しています。この関数は結果をLispのリストで返すため、うまく介入すればテキストに挿入される前にリストのレベルでソート処理を行えます。具体的にはls-lisp-handle-switches関数の直前はどうでしょうか。file-alistを独自にソートした上でswitchesにUオプションを追加してしまえばls-lispはそれ以上ソートを行いません。欠点はls-lispは使われない場合があるという点でしょう。UNIX環境でlsを使っている場合は当然として、trampなどでリモートディレクトリを参照している場合もls-lispが使われない可能性があります。

el-xmpでXMPプロパティに基づいてソート

というわけで、先日から作っているel-xmpでは、任意のXMPプロパティに基づいてdiredやimage-dired内でファイルやサムネイルをソートできるようになりました。

misohena/el-xmp: Emacs XMP (Extensible Metadata Platform) Library

xmp-setup.elを使用すれば ' S p でプロパティ名を指定してソート、 ' S - で解除できます。私は exif:DateTimeOriginal (撮影日)でソートしたいがためにこの機能を追加しました。

dired上でのソートは概ね上で説明したようなやり口で対処しています。

image-dired上では一つの画像がテキストプロパティが付いた1文字に対応するので、sort-subrを使用して1文字単位でソートしています。

あ、そうそう、結局キャッシュのためにSQLiteでデータベースを作成しました。ついでにサイドカーファイル無しでもプロパティを保存できるようにもしました(設定次第)。ただし、サイドカーファイルが無い分データベースのメンテナンスのために様々なコマンドを駆使する必要が出てきてしまっています。プロパティを付けた後にその対象ファイルを移動したり削除したりした場合、データベース内に記録されたプロパティは対象ファイルが存在しないいわば「はぐれ」状態になってしまいます。それを修正するためのコマンドを色々追加してあります。サイドカーファイルでもはぐれファイルになることはありますが比較的気がつきやすいですし対処もしやすいです。

2024-11-09 , ,

Windows Searchのデータベースを覗く(Windows11+SQLite+Emacs+org-modeで)

Windows SearchのデータベースはWindows11からSQLite3を使ったものに変更されているそうです。そんなことが色々検索をしていたときにふと目に入ったので実際にどうなっているのか調べてみました。

データベースは C:\ProgramData\Microsoft\Search\Data\Applications\Windows\ にあります。Windows10だとここには Windows.edb というファイルを中心としたファイル群があるのですが、Windows11だと Windows.dbWindows-gather.dbWindows-usn.db というファイルがあります。

早速見て行きましょう……と言いたいところですが、私のメインPCいまだにWindows10なので、Windows11のノートPCからコピーしてきました。

sqlite3コマンドを使ってデータベースの中身を調べてみました。

テーブルの一覧

Windows.db

CatalogStorageManager
SystemIndex_1
SystemIndex_1_DATA_14   SystemIndex_1_OCC_14
SystemIndex_1_DATA_35   SystemIndex_1_OCC_35
SystemIndex_1_DATA_53   SystemIndex_1_OCC_53
SystemIndex_1_DATA_74   SystemIndex_1_OCC_74
SystemIndex_1_DATA_91   SystemIndex_1_OCC_91
SystemIndex_1_DATA_112  SystemIndex_1_OCC_112
SystemIndex_1_DATA_113  SystemIndex_1_OCC_113
SystemIndex_1_DATA_114  SystemIndex_1_OCC_114
SystemIndex_1_DATA_115  SystemIndex_1_OCC_115
SystemIndex_1_DATA_116  SystemIndex_1_OCC_116
SystemIndex_1_DATA_117  SystemIndex_1_OCC_117
SystemIndex_1_DATA_118  SystemIndex_1_OCC_118
SystemIndex_1_DATA_119  SystemIndex_1_OCC_119
SystemIndex_1_DATA_120  SystemIndex_1_OCC_120
SystemIndex_1_DATA_121  SystemIndex_1_OCC_121
SystemIndex_1_DATA_122  SystemIndex_1_OCC_122
SystemIndex_1_DATA_123  SystemIndex_1_OCC_123
SystemIndex_1_DATA_124  SystemIndex_1_OCC_124
SystemIndex_1_DATA_125  SystemIndex_1_OCC_125
SystemIndex_1_DATA_126  SystemIndex_1_OCC_126
SystemIndex_1_DATA_127  SystemIndex_1_OCC_127
SystemIndex_1_DATA_128  SystemIndex_1_OCC_128
SystemIndex_1_DATA_129  SystemIndex_1_OCC_129
SystemIndex_1_Properties
SystemIndex_1_PropertyStore
SystemIndex_1_PropertyStore_Metadata

Windows-gather.db

SystemIndex_Gthr
SystemIndex_GthrAppOwner
SystemIndex_GthrPth

Windows-usn.db

ChangeTracking

各テーブルの定義

どれが何だか分かっていませんが、とりあえず全部載せておきます。

Windows.db

CREATE TABLE CatalogStorageManager (
  CatalogName TEXT_TEXT(127) DEFAULT NULL,
  CatalogID   INTEGER        PRIMARY KEY,
  IsDeleted   LONG_INTEGER   DEFAULT 0,
    UNIQUE (CatalogName COLLATE "UNICODE_en-US" ASC, CatalogID ASC)
);

CREATE TABLE SystemIndex_1_Properties (
  IndexID    LONG_INTEGER DEFAULT NULL,
  PropertyID LONG_INTEGER DEFAULT NULL,
  Property   LONGBINARY_BLOB(2147483647) DEFAULT NULL,
    PRIMARY KEY (IndexID ASC, PropertyID ASC)
) WITHOUT ROWID;

CREATE TABLE SystemIndex_1_PropertyStore_Metadata (
  Id          INTEGER PRIMARY KEY,
  UniqueKey   TEXT    NOT NULL UNIQUE,
  Name        TEXT    NOT NULL,
  PropertyId  INTEGER NOT NULL,
  VariantType INTEGER NOT NULL,
  StorageType INTEGER NOT NULL,
  MaxSize     INTEGER,
  Flags       INTEGER NOT NULL
);

CREATE TABLE SystemIndex_1_PropertyStore (
  WorkId   INTEGER NOT NULL,
  ColumnId INTEGER NOT NULL,
  Value    BLOB NOT NULL,
    PRIMARY KEY (WorkId, ColumnId)
) WITHOUT ROWID;

CREATE TABLE SystemIndex_1 (
  IndexID          INTEGER PRIMARY KEY,
  IndexDescription LONGBINARY_BLOB(5104) DEFAULT NULL
);

CREATE TABLE SystemIndex_1_DATA_14 (
  Partition UNSIGNEDLONG_INTEGER DEFAULT NULL,
  szKey    LONGBINARY_BLOB(1664)  DEFAULT NULL,
  PID      UNSIGNEDSHORT_INTEGER  DEFAULT NULL,
  WidStart UNSIGNEDLONG_INTEGER   DEFAULT NULL,
  Data     LONGBINARY_BLOB(32686) DEFAULT NULL,
    PRIMARY KEY (Partition ASC, szKey ASC, PID ASC, WidStart ASC)
) WITHOUT ROWID;

CREATE TABLE SystemIndex_1_OCC_14 (OccID UNSIGNEDLONG_INTEGER DEFAULT NULL,
  OccPage LONGBINARY_BLOB(32650) DEFAULT NULL,
    PRIMARY KEY (OccID ASC)
) WITHOUT ROWID;


CREATE TABLE SystemIndex_1_DATA_35 (
  Partition UNSIGNEDLONG_INTEGER DEFAULT NULL,
  szKey LONGBINARY_BLOB(1664) DEFAULT NULL,
  PID UNSIGNEDSHORT_INTEGER DEFAULT NULL,
  WidStart UNSIGNEDLONG_INTEGER DEFAULT NULL,
  Data LONGBINARY_BLOB(32686) DEFAULT NULL,
    PRIMARY KEY (Partition ASC, szKey ASC, PID ASC, WidStart ASC)) WITHOUT ROWID;
CREATE TABLE SystemIndex_1_OCC_35 (
  OccID UNSIGNEDLONG_INTEGER DEFAULT NULL,
  OccPage LONGBINARY_BLOB(32650) DEFAULT NULL,
    PRIMARY KEY (OccID ASC)) WITHOUT ROWID;
/* 以下 35の部分が53,74,91,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129 と同様に続く */

Windows-gather.db

CREATE TABLE SystemIndex_GthrAppOwner (OwnerId UNSIGNEDLONG_INTEGER NOT NULL,
  UserSid         TEXT_TEXT(64)       NOT NULL,
  AppUserModelId  LONGTEXT_TEXT(130)  NOT NULL,
    PRIMARY KEY (OwnerId ASC)) WITHOUT ROWID;

CREATE TABLE SystemIndex_Gthr (ScopeID UNSIGNEDLONG_INTEGER NOT NULL,
  FileName                  LONGTEXT_TEXT(512) DEFAULT NULL,
  DocumentID                UNSIGNEDLONG_INTEGER NOT NULL,
  SDID                      UNSIGNEDLONG_INTEGER DEFAULT NULL,
  UserData                  LONGBINARY_BLOB(26214400) DEFAULT NULL,
  AppOwnerId                UNSIGNEDLONG_INTEGER DEFAULT NULL,
  LastModified              BINARY_BLOB(8) DEFAULT NULL,
  RequiredSIDs              BINARY_MULTI_BLOB(68) DEFAULT NULL,
  DeletedCount              LONG_INTEGER DEFAULT NULL,
  TransactionFlags          LONG_INTEGER DEFAULT NULL,
  TransactionExtendedFlags  LONG_INTEGER DEFAULT NULL,
  CrawlNumberCrawled        LONG_INTEGER DEFAULT NULL,
  StartAddressIdentifier    UNSIGNEDSHORT_INTEGER DEFAULT NULL,
  Priority                  UNSIGNEDBYTE_INTEGER DEFAULT NULL,
  RunTime                   UNSIGNEDLONG_INTEGER DEFAULT NULL,
  FailureUpdateAttempts     UNSIGNEDBYTE_INTEGER DEFAULT NULL,
  ClientID                  UNSIGNEDLONG_INTEGER DEFAULT NULL,
  LastRequestedRunTime      UNSIGNEDLONG_INTEGER DEFAULT NULL,
  StorageProviderId         LONGTEXT_TEXT(130) DEFAULT NULL,
  CalculatedPropertyFlags   UNSIGNEDLONG_INTEGER DEFAULT NULL,
    PRIMARY KEY (ScopeID ASC, FileName COLLATE "UNICODE_en-US_LINGUISTIC_IGNORECASE" ASC)) WITHOUT ROWID;

CREATE TABLE SystemIndex_GthrPth (Scope UNSIGNEDLONG_INTEGER DEFAULT NULL,
  Parent UNSIGNEDLONG_INTEGER DEFAULT NULL,
  Name LONGTEXT_TEXT(1000) DEFAULT NULL,
    PRIMARY KEY (Scope ASC)) WITHOUT ROWID;

Windows-usn.db

CREATE TABLE ChangeTracking (Client UNSIGNEDSHORT_INTEGER NOT NULL,
  Batch UNSIGNEDBYTE_INTEGER NOT NULL,
  Path LONGTEXT_TEXT(1000) DEFAULT NULL,
  CaseMap LONGTEXT_TEXT(1000) DEFAULT NULL,
  CurrentEntry BINARY_BLOB(56) DEFAULT NULL,
  MoveSource LONGTEXT_TEXT(1000) DEFAULT NULL,
  OldEntry BINARY_BLOB(56) DEFAULT NULL,
  MoveDestination LONGTEXT_TEXT(1000) DEFAULT NULL,
  RangeInformation LONGBINARY_BLOB(2147483647) DEFAULT NULL,
    PRIMARY KEY (Client ASC, Batch ASC, Path ASC)) WITHOUT ROWID;

プロパティ(メタデータ)に関する部分

全部くまなく解析するつもりも無いので、とりあえずパッと見一番分かりやすいファイルのプロパティに関する部分を見てみます。

SystemIndex_1_PropertyStore_Metadata

テーブル SystemIndex_1_PropertyStore_Metadata の定義は次のようになっています。

CREATE TABLE SystemIndex_1_PropertyStore_Metadata (
  Id          INTEGER PRIMARY KEY,
  UniqueKey   TEXT    NOT NULL UNIQUE,
  Name        TEXT    NOT NULL,
  PropertyId  INTEGER NOT NULL,
  VariantType INTEGER NOT NULL,
  StorageType INTEGER NOT NULL,
  MaxSize     INTEGER,
  Flags       INTEGER NOT NULL
);

実際の中身を見てみると次のようになっています。

select * from SystemIndex_1_PropertyStore_Metadata limit 40
Id UniqueKey Name PropertyId VariantType StorageType MaxSize Flags
1 4703-System_Null System.Null 4703 1 13 512 22
2 4583-System_Photo_GainControlText System.Photo.GainControlText 4583 31 11 512 55
3 4224-System_Contact_BusinessHomePage System.Contact.BusinessHomePage 4224 31 11 512 55
4 4435-System_IsIncomplete System.IsIncomplete 4435 11 8   54
5 4449-System_ItemSubType System.ItemSubType 4449 31 11 512 55
6 4412-System_History_SelectionCount System.History.SelectionCount 4412 19 4   22
7 4413-System_History_TargetUrlHostName System.History.TargetUrlHostName 4413 31 11 512 55
8 4622-System_SDID System.SDID 4622 19 4   22
9 4359-System_DRM_IsProtected System.DRM.IsProtected 4359 11 8   54
10 4345-System_Contact_SpouseName System.Contact.SpouseName 4345 31 11 256 55
11 4358-System_DRM_IsDisabled System.DRM.IsDisabled 4358 11 8   54
12 4361-System_DateAcquired System.DateAcquired 4361 64 12 8 54
13 4434-System_IsFolder System.IsFolder 4434 11 8   54
14 4513-System_Message_BccAddress System.Message.BccAddress 4513 65 13 5120 119
15 4472-System_MIMEType System.MIMEType 4472 31 11 512 55
16 4430-System_IsDeleted System.IsDeleted 4430 11 8   54
17 4397-System_FilePlaceholderStatus System.FilePlaceholderStatus 4397 19 4   22
18 4660-System_StorageProviderFileIdentifier System.StorageProviderFileIdentifier 4660 31 11 512 54
19 4514-System_Message_BccName System.Message.BccName 4514 65 13 5120 119
20 4661-System_StorageProviderFileVersion System.StorageProviderFileVersion 4661 31 11 512 54
21 27F-System_Search_Rank System.Search.Rank 27 3 3   10
22 4515-System_Message_CcAddress System.Message.CcAddress 4515 65 13 5120 119
23 4407-System_GPS_LatitudeRef System.GPS.LatitudeRef 4407 31 11 512 54
24 4370-System_Document_Contributor System.Document.Contributor 4370 65 13 10240 119
25 28-System_Search_HitCount System.Search.HitCount 28 3 3   6
26 4630F-System_Search_GatherTime System.Search.GatherTime 4630 64 12 8 58
27 4516-System_Message_CcName System.Message.CcName 4516 65 13 5120 119
28 4256-System_Contact_HomeAddress1Street System.Contact.HomeAddress1Street 4256 31 11 2048 55
29 4624-System_Search_AccessCount System.Search.AccessCount 4624 19 4   22
30 4440-System_ItemFolderPathDisplay System.ItemFolderPathDisplay 4440 31 11 65536 51
31 4253-System_Contact_HomeAddress1Locality System.Contact.HomeAddress1Locality 4253 31 11 2048 55
32 4241-System_Contact_EmailAddress2 System.Contact.EmailAddress2 4241 31 11 256 55
33 4447-System_ItemPathDisplay System.ItemPathDisplay 4447 31 11 65536 51
34 4255-System_Contact_HomeAddress1Region System.Contact.HomeAddress1Region 4255 31 11 2048 55
35 4632-System_Search_LastIndexedTotalTime System.Search.LastIndexedTotalTime 4632 5 7   22
36 4252-System_Contact_HomeAddress1Country System.Contact.HomeAddress1Country 4252 31 11 2048 55
37 4254-System_Contact_HomeAddress1PostalCode System.Contact.HomeAddress1PostalCode 4254 31 11 2048 55
38 4174-System_Communication_AccountName System.Communication.AccountName 4174 31 11 512 55
39 33-System_ItemUrl System.ItemUrl 33 31 11 65536 51
40 4436-System_IsRead System.IsRead 4436 11 8   54
select count(*) from SystemIndex_1_PropertyStore_Metadata
605

全部で605行ありました。(Windows Searchは拡張可能な仕組みなので環境によって異なると思います)

これらはプロパティの種類に関する情報でしょう。プロパティのID、名前、型といった情報が見て取れます。

Nameに書かれている文字列には見覚えがあります。Windows Search SQLで検索したときにはこのプロパティ名を列名として指定しました(Windows Search SQL 構文の概要 - Win32 apps | Microsoft Learn)。

VariantTypeはWindows開発ではおなじみのやつですね。Windows SDKのWTypes.hから抜粋。

enum VARENUM
    {   VT_EMPTY    = 0,
    VT_NULL = 1,
    VT_I2   = 2,
    VT_I4   = 3,
    VT_R4   = 4,
    VT_R8   = 5,
    VT_CY   = 6,
    VT_DATE = 7,
    VT_BSTR = 8,
    VT_DISPATCH = 9,
    VT_ERROR    = 10,
    VT_BOOL = 11,
    VT_VARIANT  = 12,
    VT_UNKNOWN  = 13,
    VT_DECIMAL  = 14,
    VT_I1   = 16,
    VT_UI1  = 17,
    VT_UI2  = 18,
    VT_UI4  = 19,
    VT_I8   = 20,
    VT_UI8  = 21,
    VT_INT  = 22,
    VT_UINT = 23,
    VT_VOID = 24,
    VT_HRESULT  = 25,
    VT_PTR  = 26,
    VT_SAFEARRAY    = 27,
    VT_CARRAY   = 28,
    VT_USERDEFINED  = 29,
    VT_LPSTR    = 30,
    VT_LPWSTR   = 31,
    VT_RECORD   = 36,
    VT_INT_PTR  = 37,
    VT_UINT_PTR = 38,
    VT_FILETIME = 64,
    VT_BLOB = 65,
    VT_STREAM   = 66,
    VT_STORAGE  = 67,
    VT_STREAMED_OBJECT  = 68,
    VT_STORED_OBJECT    = 69,
    VT_BLOB_OBJECT  = 70,
    VT_CF   = 71,
    VT_CLSID    = 72,
    VT_VERSIONED_STREAM = 73,
    VT_BSTR_BLOB    = 0xfff,
    VT_VECTOR   = 0x1000,
    VT_ARRAY    = 0x2000,
    VT_BYREF    = 0x4000,
    VT_RESERVED = 0x8000,
    VT_ILLEGAL  = 0xffff,
    VT_ILLEGALMASKED    = 0xfff,
    VT_TYPEMASK = 0xfff
    } ;

こいつをEmacs Lispから引けるようにしておくと便利でしょう。

(defvar my-vt-alist
  '((0 . EMPTY) (1 . NULL) (2 . I2) (3 . I4) (4 . R4) (5 . R8) (6 . CY)
    (7 . DATE) (8 . BSTR) (9 . DISPATCH) (10 . ERROR) (11 . BOOL)
    (12 . VARIANT) (13 . UNKNOWN) (14 . DECIMAL) (16 . I1) (17 . UI1)
    (18 . UI2) (19 . UI4) (20 . I8) (21 . UI8) (22 . INT) (23 . UINT)
    (24 . VOID) (25 . HRESULT) (26 . PTR) (27 . SAFEARRAY) (28 . CARRAY)
    (29 . USERDEFINED) (30 . LPSTR) (31 . LPWSTR) (36 . RECORD) (37 . INT_PTR)
    (38 . UINT_PTR) (64 . FILETIME) (65 . BLOB) (66 . STREAM) (67 . STORAGE)
    (68 . STREAMED_OBJECT) (69 . STORED_OBJECT) (70 . BLOB_OBJECT) (71 . CF)
    (72 . CLSID) (73 . VERSIONED_STREAM) (#xfff . BSTR_BLOB) (#x1000 . VECTOR)
    (#x2000 . ARRAY) (#x4000 . BYREF) (#x8000 . RESERVED) ))

(defun my-vt-name (vt-id)
  (alist-get vt-id my-vt-alist))
;; TEST: (my-vt-name 31) => LPWSTR

これをorg-modeの表にcolumn formulaとして設定すれば次のような表も簡単に生成できます。

| Id | Name                                  | VariantType | (VT Name)| StorageType | MaxSize | Flags |
|----+---------------------------------------+-------------+----------+-------------+---------+-------|
| 25 | 'System.Search.HitCount'              |           3 | I4       |           3 |    NULL |     6 |
| 26 | 'System.Search.GatherTime'            |          64 | FILETIME |          12 |       8 |    58 |
| 27 | 'System.Message.CcName'               |          65 | BLOB     |          13 |    5120 |   119 |
| 28 | 'System.Contact.HomeAddress1Street'   |          31 | LPWSTR   |          11 |    2048 |    55 |
| 29 | 'System.Search.AccessCount'           |          19 | UI4      |           4 |    NULL |    22 |
| 30 | 'System.ItemFolderPathDisplay'        |          31 | LPWSTR   |          11 |   65536 |    51 |
| 31 | 'System.Contact.HomeAddress1Locality' |          31 | LPWSTR   |          11 |    2048 |    55 |
#+TBLFM: $4='(my-vt-name $3);L

さらにEmacs 29で加わった組み込みSQLite機能を使ってプロパティの Id から型情報を割り出せるようにしておきましょう。

(defvar my-wds-property-alist
  (let ((db (sqlite-open "Windows.db")))
    (unwind-protect
        (sqlite-select db "select * from SystemIndex_1_PropertyStore_Metadata")
      (sqlite-close db))))

(defun my-wds-property-name (id) (nth 2 (assq id my-wds-property-alist)))
(defun my-wds-property-vt (id) (nth 4 (assq id my-wds-property-alist)))
(defun my-wds-property-vt-name (id) (my-vt-name (my-wds-property-vt id)))
;; TEST: (cons (my-wds-property-name 30) (my-wds-property-vt-name 30)) => ("System.ItemFolderPathDisplay" . LPWSTR)

後はMaxSizeは良いとして、StorageTypeやFlagsは私ではすぐには思い当たらないです。

SystemIndex_1_PropertyStore

肝心要の実際のプロパティ値はどこにあるのかというと SystemIndex_1_PropertyStore というテーブルにあります。

定義は次の通り。

CREATE TABLE SystemIndex_1_PropertyStore (
  WorkId   INTEGER NOT NULL,
  ColumnId INTEGER NOT NULL,
  Value    BLOB NOT NULL,
    PRIMARY KEY (WorkId, ColumnId)
) WITHOUT ROWID;

実際の中身ですが、こちらはBLOBが入っているせいで文字化けしたので .dump した結果から抜粋しました。

WorkId ColumnId Value
1 13 1
1 17 6
1 26 X'2925c18d31b6d901'
1 29 0
1 30 'C:\ProgramData\Microsoft\Windows'
1 33 'C:\ProgramData\Microsoft\Windows\スタート メニュー'
1 35 0.0
1 39 'file:C:/ProgramData/Microsoft/Windows/Start Menu'
1 188 X'00ea2bc7d261d801'
1 295 0
1 306 0
1 308 X'00ea2bc7d261d801'
1 342 X'66006f006c00640065007200'
1 349 X'5600f5bf02b2cf41'
1 376 'file'
1 414 'Windows'
1 420 'ファイル フォルダー'
1 432 'スタート メニュー'
1 438 8209
1 441 X'3868c8f55df9d801'
1 444 X'00ea2bc7d261d801'
1 445 X'00b352c6d261d801'
1 448 X'3868c8f55df9d801'
1 449 X'e3efa68d31b6d901'
1 462 'スタート メニュー'
1 463 'フォルダー'
1 464 'Windows (C:\ProgramData\Microsoft)'
1 466 'スタート メニュー'
1 470 'スタート メニュー'
1 564 'スタート メニュー (C:\ProgramData\Microsoft\Windows)'
1 567 'Directory'
1 581 'Start Menu'
1 585 'Start Menu'
1 587 1887437183
1 596 X'28126512'
1 597 X'ba0a9bc7980a1de2357c65c73ae167b6'
2 13 1
2 17 6
2 26 X'b7a34f8331b6d901'
2 29 0
2 30 'C:\'
2 33 'C:\ユーザー'
2 35 0.109375
2 39 'file:C:/Users'

以降もこの調子で1行に1つのプロパティが大量に並んでいます。

WorkId というのがオブジェクトの識別番号のようです。

ColumnId は先ほど見たテーブル SystemIndex_1_PropertyStore_MetadataId 列に対応しています。どうも旧形式のデータベースでは一つの行が一つのファイルで、プロパティは一つ一つの列だったのだとか。SQLiteに移行したときに設計を変えたようです。プロパティは存在しないものが多いのでプロパティを列にするとnullだらけになってしまいます。それでもインタフェースとしてはそちらの方が直感的ですよね。

Value はプロパティの値です。テーブルの定義上ではBLOB型(Value BLOB NOT NULL)となっているのですが、実際に格納されている型は様々なようで、 SystemIndex_1_PropertyStore_MetadataVariantType 列に書かれている形式で格納されているみたいです。

これも先ほどやったようにorg-modeの表でプロパティ名と型名を補ってみると次のようになります。

| WorkId | ColumnId | (PropertyName)                     | (VTName) | Value                                                |
|--------+----------+------------------------------------+----------+------------------------------------------------------|
|      1 |       13 | System.IsFolder                    | BOOL     | 1                                                    |
|      1 |       17 | System.FilePlaceholderStatus       | UI4      | 6                                                    |
|      1 |       26 | System.Search.GatherTime           | FILETIME | X'2925c18d31b6d901'                                  |
|      1 |       29 | System.Search.AccessCount          | UI4      | 0                                                    |
|      1 |       30 | System.ItemFolderPathDisplay       | LPWSTR   | 'C:\ProgramData\Microsoft\Windows'                   |
|      1 |       33 | System.ItemPathDisplay             | LPWSTR   | 'C:\ProgramData\Microsoft\Windows\スタート メニュー' |
|      1 |       35 | System.Search.LastIndexedTotalTime | R8       | 0.0                                                  |
|      1 |       39 | System.ItemUrl                     | LPWSTR   | 'file:C:/ProgramData/Microsoft/Windows/Start Menu'   |
|      1 |      188 | System.DateImported                | FILETIME | X'00ea2bc7d261d801'                                  |
|      1 |      295 | System.IsAttachment                | BOOL     | 0                                                    |
|      1 |      306 | System.IsEncrypted                 | BOOL     | 0                                                    |
|      1 |      308 | System.ItemDate                    | FILETIME | X'00ea2bc7d261d801'                                  |
|      1 |      342 | System.Kind                        | BLOB     | X'66006f006c00640065007200'                          |
|      1 |      349 | System.ThumbnailCacheId            | UI8      | X'5600f5bf02b2cf41'                                  |
|      1 |      376 | System.Search.Store                | LPWSTR   | 'file'                                               |
|      1 |      414 | System.ItemFolderNameDisplay       | LPWSTR   | 'Windows'                                            |
|      1 |      420 | System.ItemTypeText                | LPWSTR   | 'ファイル フォルダー'                                |
|      1 |      432 | System.ItemNameDisplay             | LPWSTR   | 'スタート メニュー'                                  |
#+TBLFM: $3='(my-wds-property-name $2);L::$4='(my-wds-property-vt-name $2);L

System.ItemUrl が完全なパスを表しています。 System.ItemPathDisplay は Display と付いているように一部のファイル名がローカライズされてしまうので使いづらいんですよね。以前EmacsからWindows Searchするものを作ったときに困った記憶があります。

WorkId=1はスタートメニューフォルダを表しているようですが、あまりピンとこないので何か写真のプロパティを取得してみましょう。

まずはファイルを検索。といってもこのデータベースはノートPCのものなのでインデックスの範囲は極力絞ってありますし、その範囲に写真は入っていません。とりあえずテスト画像を作ってPicturesフォルダに入れて認識させ、再度データベースをメインPCにコピー。そして次のSQLで検索。

select * from SystemIndex_1_PropertyStore where ColumnId=39 and Value LIKE 'file:C:/Users/USERNAME/Pictures/%.jpg';
WorkId ColumnId Value
3338 39 file:C:/Users/USERNAME/Pictures/PXL_20241025_040712150.jpg

新しい写真の WorkId3338 になったようです。これをキーに全プロパティを取得してみましょう。

select * from SystemIndex_1_PropertyStore where WorkId=3338;

……と、実際やってみましたが、これもBLOBが文字化けしてしまったのでダンプしたデータから抜粋します(ついでにプロパティ名と型名も補っておきます)。

WorkId ColumnId (PropertyName) (VTName) Value
3338 13 System.IsFolder BOOL 0
3338 15 System.MIMEType LPWSTR 'image/jpeg'
3338 17 System.FilePlaceholderStatus UI4 6
3338 23 System.GPS.LatitudeRef LPWSTR 'N'
3338 26 System.Search.GatherTime FILETIME X'b183b6f95232db01'
3338 29 System.Search.AccessCount UI4 0
3338 30 System.ItemFolderPathDisplay LPWSTR 'C:\ユーザー\USERNAME\ピクチャ'
3338 33 System.ItemPathDisplay LPWSTR 'C:\ユーザー\USERNAME\ピクチャ\PXL_20241025_040712150.jpg'
3338 35 System.Search.LastIndexedTotalTime R8 0.0
3338 39 System.ItemUrl LPWSTR 'file:C:/Users/USERNAME/Pictures/PXL_20241025_040712150.jpg'
3338 47 System.ItemParticipants BLOB X'5400610072006f000000480061006e0061006b006f00'
3338 60 System.Photo.FlashText LPWSTR 'フラッシュなし (強制)'
3338 75 System.Photo.ExposureTime R8 0.00665899999999999998
3338 85 System.Photo.FNumber R8 2.20000000000000017
3338 93 System.FileOwner LPWSTR 'MYCOMPUTER\USERNAME'
3338 110 System.Image.HorizontalSize UI4 4080
3338 114 System.Photo.MeteringModeText LPWSTR '中央重点測光'
3338 116 System.Image.VerticalSize UI4 3072
3338 122 System.Image.HorizontalResolution R8 96.0
3338 126 System.Image.VerticalResolution R8 96.0
3338 130 System.Image.BitDepth UI4 24
3338 143 System.Photo.WhiteBalance UI4 0
3338 153 System.Image.Dimensions LPWSTR '‪4080 x 3072‬'
3338 172 System.GPS.Date FILETIME X'0020d86ad526db01'
3338 185 System.GPS.LongitudeRef LPWSTR 'E'
3338 188 System.DateImported FILETIME X'00a2f2dd5032db01'
3338 204 System.Media.ClassPrimaryID LPWSTR '{6FB2E74A-B8CB-40BB-93F3-FAC5F00FA203}'
3338 270 System.Media.DlnaProfileID BLOB X'4a005000450047005f004c0052004700'
3338 279 System.NotUserContent BOOL 0
3338 287 System.Photo.FocalLengthInFilm UI2 16
3338 290 System.Photo.OrientationText LPWSTR '標準'
3338 294 System.ItemAuthors BLOB X'5400610072006f000000480061006e0061006b006f00'
3338 295 System.IsAttachment BOOL 0
3338 306 System.IsEncrypted BOOL 0
3338 308 System.ItemDate FILETIME X'60bb495e9326db01'
3338 313 System.Photo.CameraManufacturer LPWSTR 'Google'
3338 316 System.Photo.CameraModel LPWSTR 'Pixel 7'
3338 322 System.Photo.Orientation UI2 1
3338 342 System.Kind BLOB X'7000690063007400750072006500'
3338 349 System.ThumbnailCacheId UI8 X'e7698c11b08a5156'
3338 356 System.Photo.ShutterSpeed R8 7.23000000000000042
3338 360 System.VolumeId CLSID X'89cae2bf542043d99ecc540d185f11f5'
3338 361 System.Photo.Aperture R8 2.2799999999999998
3338 366 System.Photo.ExposureBias R8 0.0
3338 370 System.GPS.LatitudeDecimal R8 35.6262194166666645
3338 374 System.Photo.SubjectDistance R8 4294967295.0
3338 376 System.Search.Store LPWSTR 'file'
3338 377 System.Photo.MeteringMode UI2 2
3338 384 System.Photo.Flash UI1 16
3338 386 System.Photo.FocalLength R8 2.35000000000000008
3338 387 System.Photo.ExposureProgram UI4 2
3338 396 System.Photo.ISOSpeed UI2 470
3338 407 System.ContentType LPWSTR 'image/jpeg'
3338 414 System.ItemFolderNameDisplay LPWSTR 'ピクチャ'
3338 424 System.Title LPWSTR 'Senburi near Icchodaira'
3338 429 System.Author BLOB X'5400610072006f000000480061006e0061006b006f00'
3338 430 System.Keywords BLOB X'530065006e0062007500720069000000540061006b0061006f00'
3338 432 System.ItemNameDisplay LPWSTR 'PXL_20241025_040712150.jpg'
3338 434 System.FileExtension LPWSTR '.jpg'
3338 436 System.Size UI8 X'bfdb4d0000000000'
3338 438 System.FileAttributes UI4 32
3338 441 System.DateModified FILETIME X'00e4a1b35232db01'
3338 444 System.Document.DateCreated FILETIME X'00a2f2dd5032db01'
3338 445 System.DateCreated FILETIME X'ce669cdd5032db01'
3338 448 System.Document.DateSaved FILETIME X'00e4a1b35232db01'
3338 449 System.DateAccessed FILETIME X'919582f95232db01'
3338 459 System.Photo.ProgramModeText LPWSTR '標準のプログラム'
3338 461 System.ApplicationName LPWSTR 'HDR+ 1.0.641377693zd'
3338 462 System.ItemName LPWSTR 'PXL_20241025_040712150.jpg'
3338 463 System.KindText LPWSTR 'ピクチャ'
3338 464 System.ItemFolderPathDisplayNarrow LPWSTR 'ピクチャ (C:\ユーザー\USERNAME)'
3338 466 System.ItemNameDisplayWithoutExtension LPWSTR 'PXL_20241025_040712150'
3338 469 System.GPS.LongitudeDecimal R8 139.230677777777771
3338 477 System.GPS.Latitude BLOB X'000000000080414000000000008042400cb81e85eb314140'
3338 478 System.Photo.MaxAperture R8 2.2799999999999998
3338 482 System.GPS.Longitude BLOB X'00000000006061400000000000002a40f91f85eb51384940'
3338 483 System.Photo.TagViewAggregate BLOB X'530065006e0062007500720069000000540061006b0061006f00'
3338 491 System.Photo.ExposureProgramText LPWSTR '標準'
3338 493 System.Photo.SaturationText LPWSTR '標準'
3338 520 System.Photo.WhiteBalanceText LPWSTR '自動'
3338 557 System.ComputerName LPWSTR 'MYCOMPUTER'
3338 564 System.ItemPathDisplayNarrow LPWSTR 'PXL_20241025_040712150 (C:\ユーザー\USERNAME\ピクチャ)'
3338 567 System.ItemType LPWSTR '.jpg'
3338 572 System.Photo.SharpnessText LPWSTR '標準'
3338 579 System.Photo.DateTaken FILETIME X'60bb495e9326db01'
3338 580 System.Photo.ContrastText LPWSTR '標準'
3338 581 System.FileName LPWSTR 'PXL_20241025_040712150.jpg'
3338 584 System.Photo.DigitalZoom R8 0.0
3338 585 System.ParsingName LPWSTR 'PXL_20241025_040712150.jpg'
3338 587 System.SFGAOFlags UI4 1077936503
3338 596 InvertedOnlyPids BLOB X'28126512'
3338 597 InvertedOnlyMD5 BLOB X'0ace870441760f5a3225b96131c5f0ba'

様々なメタデータが格納されていることが分かります。

例えば System.Title を見ると 'Senburi near Icchodaira' となっています。この写真は先日高尾山でセンブリの花を撮ったものだったので、あらかじめexiftoolでテスト的にタイトルを付けておいたのでした。データベースの中でどうなっているのか知りたかったので。x-default(英語)、英語、日本語と三つの言語でタイトルを付けておいたのですが、x-default(英語)のみが収集されたみたいです。

System.Author を見るとBLOB型で X'5400610072006f000000480061006e0061006b006f00' となっています。BLOBと言っていますがどう見てもWSTR(UTF-16LE)ですよね。次のようなEmacs Lispを使えばデコード出来ます。

(defun my-decode-wstr (hexstr)
  (decode-coding-string
   (apply 'unibyte-string
          (cl-loop for i below (length hexstr) by 2
                   collect (string-to-number (substring hexstr i (+ i 2)) 16)))
   'utf-16le))

実際にデコードすると次のようになります。

(my-decode-wstr "5400610072006f000000480061006e0061006b006f00")
"Taro\0Hanako"

あらかじめexiftoolで作者(dc:creator)をTaroとHanakoの二名にしておいたのですが、NUL文字区切りで記録されていました。なるほどだからBLOBにしているのかもしれませんね。(追記:SQLiteは文字列にNUL文字を含めることができるそうです。一部の文字列関数が最初のNUL文字までしか認識しないといった制限はあるみたいですが。なので必ずしもNULが入っているからといってBLOBにする必要は無かったかもしれません。参考:NUL Characters In Strings - sqlite.org)

System.Keywords も同様にデコードしてみましょう。

(my-decode-wstr "530065006e0062007500720069000000540061006b0061006f00")
"Senburi\0Takao"

こちらもSenburiとTakaoの二つをdc:subjectに設定しておいたのですが、このようにNUL文字区切りで記録されていました。

GPS情報なんかも格納されているので是非場所を割り出してみてください。何かプレゼントでも隠しておけたら面白かったのですが(笑) って、Decimalバージョンもあるのね。

というわけで、最低限プロパティの部分がどのように格納されているかは分かったと思います。

org-mode文書にSQLiteのクエリと実行結果を埋め込む方法

ちなみに今回の記事はorg-modeのSQLiteソースコードブロックを使用して書きました。詳しい書き方については次のページをご覧下さい。

SQLite Source Code Blocks in Org Mode

上の方では実際に次のように書いていました。

#+begin_src sqlite :db Windows.db :header :exports both :eval no-export
select * from SystemIndex_1_PropertyStore_Metadata limit 40
#+end_src

#+RESULTS:
| Id | UniqueKey         | Name         | PropertyId | VariantType | StorageType | MaxSize | Flags |
|  1 | 4703-System_Null  | System.Null  |       4703 |           1 |          13 |     512 |    22 |
...略...

:header を付けると見出しが付きます(デフォルトでは付きません)。

デフォルトはコードのみのエクスポートだったので、 :exports both を指定しました。

エクスポート時に勝手に評価しないように :eval no-export を指定しました。

Emacs Lispからsqliteにアクセスする

また上の方でも少し使いましたが、Emacs 29から組み込みのsqlite関数が入っています。

Database (GNU Emacs Lisp Reference Manual)

使用例:

(let ((db (sqlite-open "~/test-sqlite.db")))
  (unwind-protect
      (progn
        ;; ファイル情報テーブルを作る
        (sqlite-execute
         db
         "create table if not exists files (
          id integer primary key,
          path text not null unique,
          mod_time real not null,
          size integer not null)")

        ;; カレントディレクトリにある全ファイルのフルパス、更新日時、サイズを挿入する
        ;; (本当は一文でやった方が速いみたい values (?,?,?),(?,?,?)...)
        (cl-loop for (file . attrs) in (directory-files-and-attributes "." t)
                 do (sqlite-execute
                     db
                     "insert or replace into files (path,mod_time,size) values (?,?,?)"
                     (list file
                           (float-time (file-attribute-modification-time attrs))
                           (file-attribute-size attrs))))

        ;; 更新日時降順に ((パス 更新日時)...) を取得する
        (sqlite-execute
         db
         "select path,datetime(mod_time,'unixepoch','localtime') from files where mod_time >= ? order by mod_time desc"
         ;; 過去30日分取得
         (list (- (float-time) (* 30 24 60 60)))))
    ;; エラーが出てもちゃんと閉じるよ(しなくてもGCのタイミングで閉じるけど)
    (sqlite-close db)))

……というわけで色々やってみましたが、終わってみるとこれはEmacsのSQLite関連機能を学ぶためのちょうど良いチュートリアルでした。

2024-11-06 ,

XMPメタデータエディタ、ファイル形式、EXIF、データベース

前々回前回に引き続き、el-xmpの話。

xmp-editor.elを追加してプロパティを一括で編集できるようになりました。

Diredからなら xmp-dired-do-edit-properties コマンド(' e p)、その他のファイル名が検出できるところからなら xmp-edit-file-properties コマンドで次のような編集バッファを表示できます。

複数ファイルのメタデータを編集しているところ
図1: 複数ファイルのメタデータを編集しているところ

image-diredには image-dired-dired-edit-comment-and-tags というコマンドがあってこれと似たようなことができますが「コメント」と「タグ」のみの編集となっています。こちらは好きなだけ編集項目を増やせるのが良い所です(ユーザーが独自のプロパティを定義することも可能です)。そもそも対象は画像ファイルに限定されません。サムネイルは画像ファイルの時だけimage-diredの仕組みを利用して表示しています。

デフォルトではカスタマイズ変数で指定されているプロパティのみが表示されますが、プレフィックス引数(C-u 0 ' e p)を使うかまたは末尾が-allとなっているコマンド(' e a)を使えばel-xmpが認識できる全てのプロパティを表示させることもできます。

全てのプロパティを表示させてみたところ
図2: 全てのプロパティを表示させてみたところ

上のスクリーンショットは認識できる全プロパティが表示されていますが、そこには元々EXIFで記録されているカメラの情報も入っています。

今回xmp-tiff.elxmp-exif.elを追加したことによってEXIFを読み取ることも可能になりました。JPEGスキャナはAPP1セグメントのうちEXIFとXMPパケットの両方を読み取ります。読み取ったEXIFはXMPに変換されて同一ファイル内にあるXMPパケットとマージされ、元々一つのXMPパケットだったかのように扱われます。EXIFの読み取りについてはEmacs内にすでにexif.elがありますが以前調べたように多少問題があることが分かっていた(特にエンディアンバグはまだ残っているようです)ので独自に実装しました。

ついでにTIFF画像にも対応し、こちらもJPEGと同様EXIFとXMPパケットの両方を読み取ってマージします。いくつかのカメラのRAW画像形式はTIFFベースだったので全く同じコードで対応できました。とりあえずソニーのARW、キヤノンのCR2(CR3はISO BMFFベースらしいです)、ニコンのNEFが読み込めたので、それらの拡張子ではTIFFスキャナが使われるようにしました。

PDFはファイルの中に複数のXMPパケットが混在していることが多いので汎用的なパケットスキャンではかなり高い確率で間違ったXMPパケットを読み取ってしまいます。そこでxmp-pdf.elを追加してPDFもちゃんとスキャンするように頑張ってみたのですが圧縮や暗号に対応できず読めないファイルが多く残る結果となってしまいました。その代わりpdfinfoコマンドによるXMPの抽出に対応したので、必要であればインストールしてください。pdf-toolsを入れていれば多分入っているんじゃないかと思います。無理にEmacs Lispで実装するよりもこっちの方が良いでしょう。

EXIFが読めるようになったので撮影日時が取得できるようになりました。Emacs Lispのコードレベルでは (xmp-pvalue-as-text (xmp-get-file-property "example.jpg" (xmp-xml-ename xmp-exif: "DateTimeOriginal"))) で取得できます。

ちなみにEXIFが持つ基本的な日時には DateTimeDateTimeOriginalDateTimeDigitized があり、 DateTime は更新日時(xmp:ModifyDateにマップされる)、 DateTimeOriginal は元々の撮影日時、 DateTimeDigitized はデジタル化(要するにフィルムであればスキャンした日時。デジカメであれば撮影日時と同じ)となっているようです。以前のEXIF Metadata規格では DateTimeDigitizedxmp:CreateDate にマップされていたようですが、現在は exif:DateTimeDigitized にマップされています。「作成日」というのはやや曖昧な概念と言えるでしょう。

撮影日時が読めるのであればファイルを撮影日時順にソートしたくなります。しかしDiredを開くたびに全ファイルからメタデータを読み取っていては時間がかかりすぎますから、断然キャッシュが重要になってきます。現在はEmacs起動中のみ有効なキャッシュ機構がありますが、Emacsを終了させたら消えてしまいます。やはり永続的なキャッシュ機構が必要です。

また、メタデータを編集した際の保存先も現在はサイドカーファイルのみですが、 ~/.emacs.d/ の中に隠しておく選択肢もやはり欲しい所です。ディレクトリやファイルの種類によって保存先を変えられるのが理想でしょう。

となるとやはり何らかのデータベースのような仕組みが必要になります。

image-diredのように一つのファイル( ~/.emacs.d/image-dired/.image-dired_db )に全ファイル分のメタデータをテキストで保存するのは嫌でしょう。一つ編集するたびに全部書き直さなければなりません。対象ファイルが増えたら増えた分だけ全ての場所で重くなっていきます。

サムネイルと同じようにメタデータも ファイル毎 に ~/.emacs.d/ 内に保存してしまうと言う手もあるでしょう。しかしいくら一つ一つのメタデータファイルを単純化してもディレクトリを開くときに多数のファイルにアクセスするのは効率が悪いです。

ディレクトリ毎 にメタデータファイルを作成して ~/.emacs.d/ の下に保存しておくというのは比較的バランスが取れた方法かもしれません。ファイル名はディレクトリのフルパスを何らかの規則でエンコードしたものにすれば良いでしょう。

後はちゃんとしたデータベースを使うことですね。今のEmacsにはSQLiteが組み込まれているので(コンパイルオプション次第ですがWindows版公式ビルドでちゃんと使えます)、それを使うのも良さそうです。Android OSが似たようなことをすでにしていますね。あれは端末の中にあるメディアファイルのメタデータをスキャンしてSQLiteのデータベースに保持していつでも素早く情報を取得できるようにしています。それと同じような感じにすれば良さそうです。とは言えデータベースの設計は悩み所が多そうな気がします。何を保存するか、何で検索できるようにするか。例えばdc:subject内の一つ一つの主題で高速に検索できるようになっているべきか、等。一つのプロパティが単純な一つのText型ならまだ良いのですが、Array(Seq,Seq,Alt)やStruct等の構造を持っているととたんに面倒になります。単純にデータの保存先と割り切るなら最悪S式をそのまま突っ込んでしまえば良いのでしょうけど……。まぁ、自分の必要性に応じてできるだけ簡単な方法を使いましょう。