Author Archives: AKIYAMA

2024-12-06 ,

Dired内にファイルのメタデータを表示する

というわけで、el-xmpを使えばDiredの中にファイルのメタデータを表示できるようになりました。

2024-12-06-xmp-dired-filter-and-add-columns.png

もちろんソートやフィルタ、マークも可能です。

ようやくWindowsのエクスプローラでも出来るようなことがEmacsでもできるようになりました。良かった良かった。

とは言えサポートしているファイル形式がまだ限られているのでもう少し増やしたいところですね。

とりあえずID3に対応したので、MP3のアーティスト名やアルバム名なんかも表示できるようになりました。

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