最上位の「ダウンロード」内のファイルを拡張子別フォルダに振り分けるには、直下のファイルを調べ、txtやcsvなどのフォルダを作成し、移動先に同名のファイルがあればスキップするスクリプトを使います。まずは下の使い捨ての例で移動内容をプレビューし、その後、ターミナルまたはAppleショートカットから実行します。

この手順は、ばらばらのファイルをフォルダに物理的に移動し、既存のサブフォルダとその内容はそのまま残したいMacユーザー向けです。主な判断材料はプレビューレポートです。対象となる各ファイルの移動先候補が表示され、同名ファイルとの衝突や未対応の名前は、スキップする理由が明示されます。

何が移動され、何がそのまま残りますか?

Finderには、ファイルの並べ替えやグループ化のコントロールのほか、フォルダやスマートフォルダがあります。Appleによると、スマートフォルダは条件に合うファイルをまとめて表示しますが、ファイル自体は移動しません。この作業で必要なのは、ファイルを実際に移動して格納する拡張子別フォルダです。MacのFinderでファイルを整理する。

例を確認する前に、Finderを開き、Finder > 設定 > 詳細を選択して、すべてのファイル名拡張子を表示を選択します。これで、振り分けに使われる拡張子が見えるようになります。拡張子を表示しても、ファイル形式は変わりません。Macでファイル名拡張子を表示または非表示にする。

以下のスクリプトでは、次のルールを意図的に採用しています。Finderやショートカットに共通する動作ではなく、この実装に固有の説明です。

対象 振り分けスクリプトの動作
note.TXTのような通常の直下ファイル txt/note.TXTへの移動を提案し、元のファイル名を保持します
bundle.tar.gzのような複合名 最後の拡張子を使います。例:gz/bundle.tar.gz
ドットを含まないファイル、または末尾がドットのファイル そのまま残し、スキップしたことを報告します
拡張子のような名前が付いたものも含め、すべてのフォルダ フォルダとその内容をそのまま残します
ドットで始まる名前 スキャン対象から除外します
シンボリックリンク そのまま残します
ASCIIの英字と数字以外を含む拡張子、または32文字を超える拡張子 そのまま残し、スキップしたことを報告します
同じファイル名の移動先がすでに存在する場合 元のファイルをそのまま残し、衝突を報告します
移動先フォルダ名が通常のファイルまたはシンボリックリンクで占有されている場合 フォルダ名が使えないことを報告し、元のファイルをそのまま残します

拡張子に制限を設けることで、フォルダ名のルールを単純で予測しやすくしています。スキップされたファイルは手動で判断できる状態のまま残ります。スクリプトが代わりの名前を作ったり、通知なく削除したりすることはありません。

使い捨ての「ダウンロード」例を作成する

ターミナルを開き、次のセットアップブロック全体を貼り付けます。Downloads/ExtensionSortDemo内に、テスト用の合成テキストファイルだけを作成します。.gzなどの拡張子は例示用で、ファイルの内容はそれらの形式のデータではなく、通常のサンプルテキストです。デモフォルダがすでに存在する場合、セットアップは停止するため、再実行しても以前のテストがリセットされることはありません。

Appleは、ターミナルをコマンドやシェルスクリプトの実行に使う方法と、ローカルファイルの移動にmvを使う方法を説明しています。ここではターミナルを使って、実際に移動する前に各移動予定を確認します。Macのターミナルとは?、Macのターミナルでファイルを管理する。

/bin/bash <<'BASH'
set -eu
demo="$HOME/Downloads/ExtensionSortDemo"
/bin/mkdir "$demo"
/bin/mkdir "$demo/nested" "$demo/txt"
printf 'sample text\n' > "$demo/sample.txt"
printf 'uppercase suffix example\n' > "$demo/note.TXT"
printf 'name,value\nblue,1\n' > "$demo/palette.csv"
printf 'synthetic compound-name example\n' > "$demo/bundle.tar.gz"
printf 'no extension example\n' > "$demo/README"
printf 'trailing dot example\n' > "$demo/trailing."
printf 'hidden example\n' > "$demo/.hidden.txt"
printf 'nested example\n' > "$demo/nested/inside.txt"
printf 'existing destination example\n' > "$demo/txt/sample.txt"
printf 'blocks creation of a csv folder\n' > "$demo/csv"
printf 'Created demo folder: %s\n' "$demo"
BASH

Finderで「ダウンロード」を開き、ExtensionSortDemoを開きます。sample.txtという同名のファイルが2つあることを確認してください。1つはデモフォルダの最上位にあり、もう1つはすでにtxt内にあります。また、csvはファイルです。これらは、移動をスキップすべきよくある理由をテストするために意図的に用意されています。

ファイルを移動する前に振り分けをプレビューする

以下のプログラム全体をターミナルに貼り付けます。初期設定はmode=previewなので、拡張子別フォルダを作成したりファイルを移動したりせずに、移動予定を報告します。設定されているルートは使い捨てのデモフォルダであり、「ダウンロード」フォルダ全体ではありません。

/bin/bash <<'BASH'
set -u
export LC_ALL=C
unset GLOBIGNORE
shopt -s nullglob
shopt -u dotglob

root="$HOME/Downloads/ExtensionSortDemo"
mode=preview

case "$mode" in
  preview|move) ;;
  *) printf 'Invalid mode\n' >&2; exit 1 ;;
esac
if [ ! -d "$root" ] || [ -L "$root" ]; then
  printf 'Root must be an existing ordinary folder\n' >&2
  exit 1
fi
for file in "$root"/*; do
  if [ -L "$file" ] || [ ! -f "$file" ]; then
    printf 'SKIP non-regular: %q\n' "$file"
    continue
  fi
  name=${file##*/}
  case "$name" in
    *.*) ext=${name##*.} ;;
    *) printf 'SKIP no extension: %q\n' "$name"; continue ;;
  esac
  case "$ext" in
    ''|*[!A-Za-z0-9]*)
      printf 'SKIP unsupported extension: %q\n' "$name"
      continue ;;
  esac
  if [ "${#ext}" -gt 32 ]; then
    printf 'SKIP long extension: %q\n' "$name"
    continue
  fi
  ext=$(printf '%s' "$ext" | /usr/bin/tr '[:upper:]' '[:lower:]')
  folder="$root/$ext"
  dest="$folder/$name"
  if [ -L "$folder" ] || { [ -e "$folder" ] && [ ! -d "$folder" ]; }; then
    printf 'SKIP blocked folder: %q\n' "$folder"
    continue
  fi
  if [ -e "$dest" ] || [ -L "$dest" ]; then
    printf 'SKIP collision: %q\n' "$dest"
    continue
  fi
  if [ "$mode" = preview ]; then
    printf 'WOULD MOVE %q -> %q\n' "$file" "$dest"
    continue
  fi
  /bin/mkdir -p "$folder" || exit 1
  /bin/mv -n "$file" "$dest" || exit 1
  if [ -e "$file" ] || [ -L "$file" ]; then
    printf 'NOT MOVED: %q\n' "$file" >&2
    exit 1
  fi
  printf 'MOVED %q -> %q\n' "$file" "$dest"
done
printf 'Finished (%s). Review all SKIP lines.\n' "$mode"
BASH

直下のファイルを対象とするパターン"$root"/*は、ディレクトリ内には入りません。その後のファイル判定で、ディレクトリとシンボリックリンクが除外されます。nullglobは空のフォルダでも、パターンに一致しなかったものをファイル名として扱わないようにします。dotglobを無効にすると、ドットで始まる名前が除外されます。これらの動作は、Appleが配布しているBashマニュアルに記載されています。Bashマニュアル。

スクリプトは最後の拡張子を取り出し、trで英字を小文字に変換します。そのため、note.TXTはファイル名を保ったまま、txtに移動します。Appleのtrマニュアルには、ここで使われている大文字から小文字への文字変換が記載されています。tr(1) — 文字を変換する。

このテスト用ファイルで、想定される主な結果は次のとおりです。

テスト用の元ファイル 想定されるプレビュー結果
note.TXT txt/note.TXTに移動予定
bundle.tar.gz gz/bundle.tar.gzに移動予定
sample.txt txt/sample.txtと衝突するためスキップ
palette.csv ファイルがcsvというフォルダ名を占有しているためスキップ
README 拡張子がないためスキップ
trailing. 拡張子が空のためスキップ
nested/inside.txt 調べない
.hidden.txt 調べない

これは想定されるファイルシステム上の結果であり、準備中に移動が実行されたという意味ではありません。プログラムはBashの構文チェックに合格し、別途行った決定的なファイル名マッピングのチェックでは、拡張子の抽出と小文字への変換が確認されました。この準備環境は読み取り専用だったため、macOS上での移動手順全体は実行していません。

サンプルファイルを移動して結果を確認する

プレビューが表と一致したら、同じプログラム全体を再度貼り付け、mode=previewだけをmode=moveに変更します。このテストでは、デモのルートをそのままにします。

移動モードでは、mkdir -pが必要なフォルダを作成するか、既存のディレクトリをそのまま使います。既存の通常ファイルをフォルダに変えることはできません。そのため、スクリプトは移動先フォルダを事前に確認します。mkdir(1) — ディレクトリを作成する。

移動にはmacOSのmv -nを使います。マニュアルに記載されているとおり、既存ファイルを上書きしないコマンドです。事前の存在確認で、衝突を分かりやすく報告します。その後の元ファイルの確認では、移動後も元ファイルが残っていないかを検出します。コマンドの失敗が報告された場合、プログラムは停止します。mv(1) — ファイルを移動する。

Finderでデモフォルダを確認します。note.TXTがtxt内にあり、bundle.tar.gzが新しく作成されたgzフォルダ内にあるはずです。sample.txtの2つのファイルは、どちらも元の場所に残っているはずです。palette.csvは最上位に残り、nested/inside.txtはnested内に残るはずです。

最後に表示されるFinishedは、スキャンが完了したことを意味します。スキップのメッセージも確認してください。フォルダ内は再帰的にスキャンされないため、振り分けを再実行しても、すでに整理済みのファイルはそのままフォルダ内に残ります。

Appleショートカットから同じ振り分けを実行する

ショートカットをこのスクリプトの実行ボタンとして使えます。Appleは、ショートカットエディタにシェルスクリプトの記述と実行機能が組み込まれていると説明しています。拡張子の振り分けと存在確認はシェルプログラムが行います。この記事では、ネイティブのファイル移動アクションにも同じ衝突処理があるとは想定していません。macOS用ショートカットの概要。

ショートカットを手動で作成し、テスト中はデモフォルダを指定したままにします。

  1. ショートカットを開き、追加ボタンをクリックして、ショートカットにSort Extension Demoという名前を付けます。
  2. アクション一覧でRun Shell Scriptを検索して追加します。
  3. シェルを/bin/bashに設定します。冒頭の/bin/bash行と最後のBASH区切りを含め、上記の振り分けスクリプト全体をスクリプト欄に貼り付けます。最初はmode=previewのままにします。
  4. スクリプトのアクションの後にQuick Lookを追加し、スクリプトの出力につなぎます。
  5. ショートカットを実行し、スクリプトをmode=moveに変更する前にプレビューレポートを確認します。

Appleは、アクションを追加してテストしながらショートカットを作成する方法を説明し、アクション間で受け渡される内容を確認するにはQuick Lookを推奨しています。Macでカスタムショートカットを作成する、Macのショートカットでアクションをテストする。

このショートカットには入力ファイルは必要ありません。ルートパスはスクリプト内で定義されています。正常に実行されるとテキストレポートが出力され、Quick Lookで表示されます。このレポートは、別のファイルアクションに渡すための移動済みファイルオブジェクトの集まりではありません。インストール済みのバージョンで、あるアクションが受け付ける入力と生成する出力を確認するには、Appleの説明どおり、アイコンをControlキーを押しながらクリックして情報を表示を選択します。Macのショートカットでアクション一覧を操作する。

ショートカットでスクリプトの実行を有効にする必要がある場合、Appleが案内している設定はショートカット > 設定 > 詳細 > スクリプトの実行を許可です。インストール環境でシェルアクションが使えない場合は、上記のターミナル用プログラム全体を使ってください。同じ振り分け作業を単独で実行できます。Macのショートカットの詳細なプライバシーとセキュリティの設定。

最上位の「ダウンロード」にルールを適用する

使い捨てのテストを終えたら、実際の「ダウンロード」フォルダに適用するため、振り分けスクリプト内の1行を次のように置き換えます。これは設定用の断片であり、単独で振り分けを実行するプログラムではありません。

root="$HOME/Downloads"

ルートを変更したときは、mode=previewに戻ります。移動を有効にする前に、移動先の候補とスキップのメッセージをすべて確認してください。プログラムは対象となる直下のファイルをまとめて処理し、ファイルごとに選択を促す機能はありません。

ファイルのダウンロードが完了したフォルダに対して、手動で一括実行してください。このスクリプトはダウンロードの完了を判定したり、新しいファイルを監視したり、複数の実行を調整したりしません。確認と移動は別々の処理なので、実行中は同じフォルダで他のファイル移動を行わないでください。プレビューが示すのは現在の状態であり、移動先を確保するものではありません。

同名ファイルとの衝突が起きた場合は、Finderで2つのファイルを確認し、最上位にあるファイルの名前を変更するか、そのまま残すかを決めてください。拡張子別フォルダの名前が使えない場合は、その名前を占有している項目を解決してから再実行します。どちらの場合も、ファイルを上書きするようにプログラムを変更する必要はありません。

最後に、Finderで実際の移動先フォルダを確認し、最上位に残ったファイルをレポートと照らし合わせます。使い捨ての例では、具体的な受け入れテストができます。対象のファイルが移動され、既存の名前が保持され、サブフォルダの内容が元の場所に残ります。

参考資料