Finderで気軽にファイルをバージョニング・待避するAppleScript

:::note info
最も時間のかかっていたフォルダを探す処理をswiftでコマンド化しました。ストレスなく使える速さになっています。
もう一つ、待避フォルダアイコン用のicnsファイルが必要ですが、無くても動きます。
2025/05/15：親フォルダ確認処理を「aaa.widget」等のフォルダでも正しく動作するように変更

:::

gitとかやるまでもないんだよねー、という場合ありますよね、ないですか。
単に待避フォルダに移動・コピーはAppleScriptでずっとしていたんですが、ちょっとだけ豪華にしました。

適当に待避用フォルダを作成して、ファイルを入れる毎に日時フォルダに分別してコピーまたは移動し、移動の理由も付けておきたいな、という時にもテキストファイルで理由を保存しておけます。
また、複数の待避フォルダがあっても別々に使用したり、一つにマージしたりできます。

これで動作を想像してください。

![元フォルダ移動.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35477/bebf39a5-d465-4b82-9821-b3bdbf93a829.png)

こう聞いてきて
![SS_Finder_20250401-140340.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35477/4b3876a2-6268-4642-a0af-0c65db70217d.png)

こんな感じになります。

![SS_CleanShot_20250401-140221.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35477/f4c18dd2-a45e-479e-9bfc-b2dd906f9103.png)

気軽に使うためにはKeyboard Maestroのexecute AppleScriptで実行させましょう。

・icnsファイルは、ホームフォルダのPictures（写真？画像？元からあるものです）に入れてください
・scptは↑のようにスクリプトメニューからでもいいですが、Keyboard Maestroでマクロ作った方が楽しいです

※ほぼChatGPT4oですが、結構な時間かかりました。

```AppleScript
use AppleScript version "2.4"
use scripting additions
use framework "Foundation"

property targetBaseFolderName : "■待避"
property dateTimeFormat : "%Y-%m-%d_%H-%M-%S"
property reasonFileName : "★待避理由.txt"
property newFolderTagName : "重要"

--メイン
tell application "Finder"
	set myFirstSelection to selection
	
	--選択しているか
	if myFirstSelection is {} then
		display alert "エラー" message "項目が選択されていません。" as critical
		return
	end if
	
	--Basenamefolder自体を選択していないか
	
	set mySelection to my filterOutTaibiFolders(myFirstSelection, targetBaseFolderName)
	if mySelection is {} then
		display alert "「" & targetBaseFolderName & "～」フォルダ以外を選択してください" as warning
		return
	end if
	
	--検索処理：inside/none/パスリスト
	set myParentFolder to container of item 1 of mySelection as alias
	set myFindReturn to my findTargetBaseFolder(myParentFolder)
	
	if myFindReturn is "inside" then
		--待避フォルダ内からなら実行しない
		display alert "処理中止" message "選択された項目が「" & targetBaseFolderName & "」フォルダ自体、またはその中にあるようです。" as warning
		return
	else if myFindReturn is "none" then
		--待避フォルダがパス上に見つからないので新規作成
		set myDialogRet to display dialog (myParentFolder as text) & "に対応した待避フォルダが見つかりません。作成しますか？" & return & "「■待避」にテキストを追加できます。" default answer "" buttons {"キャンセル", "作成"} default button "作成"
		if button returned of myDialogRet is "キャンセル" then
			return
		else
			set myFindReturn to my createTargetBaseFolder(myParentFolder, (text returned of myDialogRet))
		end if
	end if
	
	--複数あるなら指定するかまとめる処理をして、単一フォルダパスを返す
	set myFindRetrunList to myFindReturn as list
	if (count of myFindRetrunList) > 1 then
		set myFindReturn to my promptFolderSelection(myFindRetrunList)
	end if
	
	--親フォルダがなければ作成
	set myParentFolder to name of myParentFolder
	
	set myFindParent to myFindReturn & "/" & myParentFolder
	
	set fileManager to current application's NSFileManager's defaultManager()
	set pathNSString to current application's NSString's stringWithString:myFindParent
	set existsBool to fileManager's fileExistsAtPath:pathNSString
	
	if existsBool as boolean is false then
		do shell script "mkdir -p " & quoted form of myFindParent
	end if
	
	set myFindReturn to (POSIX file myFindParent) as alias
	set b to myFindReturn
	--移動・コピー処理選択
	set myLinefeed to (linefeed & linefeed & linefeed & linefeed)
	
	set dialogReturn to display dialog "移動またはコピーを選び、必要なら理由を入力してください" & return & return & "┌移動/コピー先" & return & "　" & myFindParent & "/（日時フォルダ）/" default answer myLinefeed buttons {"キャンセル", "コピー", "移動"} default button "移動"
	set returnButton to button returned of dialogReturn
	set returnText to text returned of dialogReturn
	
	if returnButton is "キャンセル" then return
	
	--親フォルダ作成
	
	--移動・コピー処理メイン
	--set myFindReturn to (POSIX path of myFindReturn) as alias
	
	--サブフォルダ作成
	set subFolderName to do shell script "date +" & quoted form of dateTimeFormat
	set mySubFolder to make new folder at myFindReturn with properties {name:subFolderName}
	
	--移動・コピー
	if returnButton is "コピー" then
		duplicate mySelection to mySubFolder
	else if returnButton is "移動" then
		move mySelection to mySubFolder
	end if
	
	--ノート処理
	set spaceFinder to ""
	
	set spaceFinder to do shell script "echo " & quoted form of returnText & " | tr -d '[:space:]' | LANG=ja_JP sed -e 's/[[:space:]]//g'"
	
	if spaceFinder is not "" then
		set myReasonFilePath to POSIX path of (mySubFolder as alias) & reasonFileName
		do shell script "echo " & quoted form of returnText & " > " & quoted form of myReasonFilePath
		my tagOverwrite((POSIX file myReasonFilePath) as alias, newFolderTagName)
		
	end if
	
end tell

on filterOutTaibiFolders(originalList, baseName)
	set filteredList to {}
	tell application "Finder"
		repeat with i in originalList
			try
				set currentItem to i
				set myName to name of currentItem
				set myClass to class of currentItem
				-- 除外条件: (名前が baseName で「始まり」) かつ (クラスが folder)
				if not ((myName starts with baseName) and (myClass is folder)) then
					set end of filteredList to currentItem
				end if
			on error errMsg number errNum
				log "エラー処理中: " & errMsg
			end try
		end repeat
	end tell
	return filteredList
end filterOutTaibiFolders

on findTargetBaseFolder(startingFolder)
	set startPath to POSIX path of (startingFolder as alias)
	set mySCFind to do shell script "/usr/local/bin/findfolder " & quoted form of targetBaseFolderName & " " & quoted form of startPath
	
	if mySCFind is "inside" then
		return "inside"
	else if mySCFind is "none" then
		return "none"
	else if mySCFind contains ":::" then
		return my splitText(mySCFind, ":::")
	else
		return mySCFind
	end if
end findTargetBaseFolder

on createTargetBaseFolder(referenceFolder, mySuffix)
	-- 待避ベースフォルダ新規作成
	
	-- フォルダ選択 + 作成
	tell application "Finder"
		if mySuffix is not "" then set mySuffix to "：" & mySuffix
		set creationLocationRef to choose folder with prompt "「" & targetBaseFolderName & mySuffix & "」フォルダを作成する場所を選択してください:" default location referenceFolder as alias
		if creationLocationRef is false then return
		set newBaseFolderRef to make new folder at creationLocationRef with properties {name:targetBaseFolderName & mySuffix}
	end tell
	
	-- カスタムアイコンの存在確認（エラーにしない）
	set iconPath to POSIX path of (path to pictures folder) & "hfi.icns"
	try
		set iconExists to POSIX file iconPath as alias
	on error
		tell me to display alert "ホームのピクチャフォルダに、「hfi.icns」ファイルを置くと、カスタムアイコンを付けられます。"
	end try
	
	-- タグ付け
	my tagOverwrite(newBaseFolderRef, newFolderTagName)
	
	-- カスタムアイコンの設定
	try
		set imageData to (current application's NSImage's alloc()'s initWithContentsOfFile:iconPath)
		(current application's NSWorkspace's sharedWorkspace()'s setIcon:imageData forFile:(POSIX path of (newBaseFolderRef as alias)) options:2)
	end try
	
	return POSIX path of (newBaseFolderRef as alias)
end createTargetBaseFolder

on promptFolderSelection(folderNames)
	set chosenFolder to choose from list folderNames with prompt "同一階層に複数の待避フォルダが見つかりました" & return & "使用する待避フォルダを選んでください：" without multiple selections allowed
	if chosenFolder is false then error number -128
	set selectedName to item 1 of chosenFolder
	set confirmDialog to display dialog "選択した待避フォルダ" & return & selectedName & return & return & "このフォルダを使用しますか？それとも全ての待避フォルダをこのフォルダにマージしますか？" & return & "※ 同一の時刻のフォルダがある場合は上書きされます" buttons {"キャンセル", "このフォルダにマージ", "このフォルダを使用"} default button "このフォルダを使用"
	set clickedButton to button returned of confirmDialog
	
	if clickedButton is "キャンセル" then error number -128
	if clickedButton is "このフォルダにマージ" then
		--dittoでマージ処理
		set targetPath to (POSIX path of selectedName)
		tell application "Finder"
			repeat with i in folderNames
				set sourcePath to (POSIX path of i)
				if sourcePath is not equal to targetPath then
					do shell script "/usr/bin/ditto " & quoted form of sourcePath & " " & quoted form of targetPath
					try
						delete (POSIX file sourcePath as alias)
					end try
				end if
			end repeat
		end tell
	end if
	return selectedName
end promptFolderSelection

on tagOverwrite(targetRef, tagName)
	if targetRef is missing value or tagName is "" then return
	try
		set itemAlias to targetRef as alias
		set itemPathText to POSIX path of itemAlias
		set itemURL to current application's NSURL's fileURLWithPath:itemPathText
		set tagKey to current application's NSURLTagNamesKey
		itemURL's setResourceValue:{tagName} forKey:tagKey |error|:(missing value)
	on error errMsg
		tell me to display alert "タグ設定エラー" message errMsg as warning
	end try
end tagOverwrite

on splitText(t, delimiter)
	--分離処理
	set AppleScript's text item delimiters to delimiter
	set theList to text items of t
	set AppleScript's text item delimiters to ""
	return theList
end splitText

```


上のコードで呼んでいるコマンド

```swiftc -o findfolder script.swift```
でコマンドにして、```chmod +x```し/usr/local/bin/に入れてください。

```swift:findfolder
#!/usr/bin/env swift

import Foundation

let args = CommandLine.arguments

guard args.count == 3 else {
    print("Usage: find_taibi <prefix> <startPath>")
    exit(1)
}

let prefix = args[1].precomposedStringWithCanonicalMapping

// expand ~ and normalize path
let startPath = (args[2] as NSString).expandingTildeInPath

// remove trailing slash safely
let cleanedPath = startPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))

// now build URL
var currentURL = URL(fileURLWithPath: "/" + cleanedPath).standardizedFileURL

while true {
    // Check if this folder itself matches
    let currentName = currentURL.lastPathComponent.precomposedStringWithCanonicalMapping
    if currentName.hasPrefix(prefix) {
        print("inside")
        exit(0)
    }

    do {
        let contents = try FileManager.default.contentsOfDirectory(
            at: currentURL,
            includingPropertiesForKeys: [.isDirectoryKey],
            options: [.skipsHiddenFiles]
        )

        let matches = contents.filter {
            let name = $0.lastPathComponent.precomposedStringWithCanonicalMapping
            return name.hasPrefix(prefix) &&
                (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
        }

        if !matches.isEmpty {
            let joined = matches.map { $0.path }.joined(separator: ":::")
            print(joined)
            exit(0)
        }

    } catch {
        // エラーは無視
    }

    let parent = currentURL.deletingLastPathComponent().standardized
    if parent.path == currentURL.path || currentURL.path == "/" {
        break
    }
    currentURL = parent
}

print("none")
exit(0)

```



<details><summary>旧版</summary>

```AppleScript
use AppleScript version "2.4"
use scripting additions
use framework "Foundation"

property targetBaseFolderName : "■待避："
property searchDepthLimit : 4
property dateTimeFormat : "%y%m%d-%H%M%S"
property reasonFileName : "★待避理由.txt"
property newFolderTagName : "重要"

tell application "Finder"
	try
		set selectedItems to my getSelectedItems()
		set sourceParentFolder to container of item 1 of selectedItems as alias
		set sourceParentName to name of sourceParentFolder
		
		if my isInsideTargetFolder(selectedItems, targetBaseFolderName) then
			display alert "処理中止" message "選択された項目が「" & targetBaseFolderName & "」フォルダ自体、またはその中にあるようです。" as warning
			return
		end if
		
		set targetBaseFolder to my findTargetBaseFolder(sourceParentFolder)
		if targetBaseFolder is not missing value then
			set targetBaseFolderName to name of targetBaseFolder
		end if
		
		if targetBaseFolder is missing value then
			set userSuffix to my promptForFolderSuffix()
			set targetBaseFolderName to targetBaseFolderName & userSuffix
			set targetBaseFolder to my findTargetBaseFolder(sourceParentFolder)
			if targetBaseFolder is missing value then
				set targetBaseFolder to my createTargetBaseFolder(sourceParentFolder)
			end if
		end if
		
		set {chosenAction, reasonText} to my promptForAction(sourceParentName, targetBaseFolder)
		
		if targetBaseFolder is missing value then
			display alert "エラー" message "ターゲットフォルダが見つからず、処理を続行できません。" as critical
			return
		end if
		my ensureFolderTagOverwrite(targetBaseFolder, newFolderTagName)
		
		set subFolder1 to my getOrCreateFolder(targetBaseFolder, sourceParentName)
		set dateTimeFolderName to do shell script "date +" & quoted form of dateTimeFormat
		set destinationFolder to my createFolder(subFolder1, dateTimeFolderName)
		
		try
			if chosenAction is "移動" then
				move selectedItems to destinationFolder
			else if chosenAction is "コピー" then
				duplicate selectedItems to destinationFolder
			end if
			if reasonText is not "" then my writeReasonFile(destinationFolder, reasonText)
		on error errMsg
			try
				delete destinationFolder
			end try
			display alert "エラー" message chosenAction & "処理中にエラーが発生しました。" & return & errMsg as critical
		end try
	end try
end tell

on getSelectedItems()
	tell application "Finder"
		set selectedList to selection
		if selectedList is {} then
			display alert "エラー" message "項目が選択されていません。" as critical
			error number -128
		end if
		return selectedList
	end tell
end getSelectedItems

on isInsideTargetFolder(targetItems, baseName)
	if targetItems is {} then error number -128
	tell application "Finder"
		set firstSelectedItem to item 1 of targetItems
		if name of firstSelectedItem starts with baseName then return true
		set checkFolderRef to container of firstSelectedItem
		repeat until false
			try
				if name of checkFolderRef starts with baseName then return true
				set checkFolderRef to container of checkFolderRef
			on error
				exit repeat
			end try
		end repeat
	end tell
	return false
end isInsideTargetFolder

on promptForAction(parentFolderName, targetFolder)
	set targetFolderName to name of targetFolder
	set initialAnswer to (linefeed & linefeed & linefeed & linefeed)
	set dialogResult to display dialog "操作を選び、必要なら理由を入力してください：" & return & return & "移動/コピー先：" & return & "「" & targetFolderName & "」>「" & parentFolderName & "」>（日時フォルダ）" default answer initialAnswer buttons {"キャンセル", "コピー", "移動"} default button "移動"
	if button returned of dialogResult is "キャンセル" then error number -128
	set actionResult to button returned of dialogResult
	set reasonResult to text returned of dialogResult
	return {actionResult, reasonResult}
end promptForAction

on promptForFolderSuffix()
	set suffixDialog to display dialog "待避フォルダがありません。作成しますか？" & return & "これから作成する待避フォルダ名に、テキストを追加できます。" default answer "" buttons {"キャンセル", "作成"} default button "作成"
	
	if button returned of suffixDialog is "キャンセル" then
		error number -128
	else
		return text returned of suffixDialog
	end if
end promptForFolderSuffix

on findTargetBaseFolder(startingFolder)
	tell application "Finder"
		set currentFolderRef to startingFolder
		repeat searchDepthLimit times
			try
				set folderList to folders of currentFolderRef
				set foundFolders to {}
				repeat with subFolderRef in folderList
					if name of subFolderRef starts with targetBaseFolderName then set end of foundFolders to subFolderRef
				end repeat
				if (count of foundFolders) = 1 then
					return item 1 of foundFolders
				else if (count of foundFolders) > 1 then
					set folderNames to {}
					repeat with f in foundFolders
						set end of folderNames to name of f
					end repeat
					
					set chosenName to my promptFolderSelection(folderNames)
					
					if chosenName is false then return missing value
					if class of chosenName is text then
						repeat with f in foundFolders
							if name of f is chosenName then return f
						end repeat
					else if class of chosenName is list then
						set primaryFolderName to item 1 of chosenName
						set primaryFolder to missing value
						repeat with f in foundFolders
							if name of f is primaryFolderName then
								set primaryFolder to f
								exit repeat
							end if
						end repeat
						if primaryFolder is missing value then return missing value
						repeat with f in foundFolders
							set srcPath to POSIX path of (f as alias)
							set dstPath to POSIX path of (primaryFolder as alias)
							if srcPath is not equal to dstPath then
								do shell script "/usr/bin/ditto " & quoted form of srcPath & " " & quoted form of dstPath
								delete f
							end if
						end repeat
						return primaryFolder
					end if
				end if
				if class of container of currentFolderRef is desktop folder then exit repeat
				set currentFolderRef to container of currentFolderRef
			on error errMsg number errNum
				if errNum is -128 then
					error number -128
				else
					exit repeat
				end if
			end try
		end repeat
	end tell
	return missing value
end findTargetBaseFolder

on promptFolderSelection(folderNames)
	set chosenFolder to choose from list folderNames with prompt "同一階層に複数の待避フォルダが見つかりました" & return & "使用する待避フォルダを選んでください：" without multiple selections allowed
	if chosenFolder is false then error number -128
	set selectedName to item 1 of chosenFolder
	set confirmDialog to display dialog "選択した待避フォルダ: " & selectedName & return & return & "このフォルダを使用しますか？それとも全ての待避フォルダをこのフォルダにマージしますか？" & return & "※ 同一の時刻のフォルダがある場合は上書きされます" buttons {"キャンセル", "全てマージ", "このフォルダを使用"} default button "このフォルダを使用"
	set clickedButton to button returned of confirmDialog
	
	if clickedButton is "キャンセル" then error number -128
	if clickedButton is "このフォルダを使用" then return selectedName
	if clickedButton is "全てマージ" then return chosenFolder
	error number -128
end promptFolderSelection

on createTargetBaseFolder(referenceFolder)
	try
		set parentFolderRef to container of referenceFolder
	on error
		set parentFolderRef to referenceFolder
	end try
	set parentFolderAlias to parentFolderRef as alias
	set creationLocationRef to choose folder with prompt "「" & targetBaseFolderName & "」フォルダを作成する場所を選択してください:" default location parentFolderAlias
	if creationLocationRef is false then error number -128
	tell application "Finder"
		set newBaseFolderRef to make new folder at creationLocationRef with properties {name:targetBaseFolderName}
	end tell
	try
		set iconPath to POSIX path of (path to pictures folder) & "hfi.icns"
		set imageData to (current application's NSImage's alloc()'s initWithContentsOfFile:iconPath)
		(current application's NSWorkspace's sharedWorkspace()'s setIcon:imageData forFile:(POSIX path of (newBaseFolderRef as alias)) options:2)
	on error
		-- skip icon error
	end try
	return newBaseFolderRef
end createTargetBaseFolder

on createFolder(parentRef, folderLabel)
	if parentRef is missing value or folderLabel is "" then
		error number -128
	end if
	tell application "Finder"
		return make new folder at parentRef with properties {name:folderLabel}
	end tell
end createFolder

on writeReasonFile(destinationRef, userReasonText)
	if destinationRef is missing value then error number -128
	try
		set trimmedReasonText to do shell script "echo " & quoted form of userReasonText & " | tr -d '[:space:]'"
		if trimmedReasonText is "" then return
		set reasonFileFullPath to POSIX path of (destinationRef as alias) & reasonFileName
		do shell script "echo " & quoted form of userReasonText & " > " & quoted form of reasonFileFullPath
		set urlClass to current application's NSURL
		set tagKey to current application's NSURLTagNamesKey
		set fileURL to urlClass's fileURLWithPath:reasonFileFullPath
		fileURL's setResourceValue:{newFolderTagName} forKey:tagKey |error|:(missing value)
	on error errMsg
		display alert "警告" message "理由ファイルの作成またはタグ付けに失敗しました。" & return & errMsg as warning
	end try
end writeReasonFile

on ensureFolderTagOverwrite(targetFolderRef, tagName)
	if targetFolderRef is missing value or tagName is "" then error number -128
	try
		set folderAlias to targetFolderRef as alias
		set folderPathText to POSIX path of folderAlias
		set folderURL to current application's NSURL's fileURLWithPath:folderPathText
		set tagKey to current application's NSURLTagNamesKey
		folderURL's setResourceValue:{tagName} forKey:tagKey |error|:(missing value)
	on error errMsg
		display alert "タグ設定エラー" message errMsg as warning
	end try
end ensureFolderTagOverwrite

on getOrCreateFolder(parentRef, childName)
	if parentRef is missing value or childName is "" then error number -128
	try
		set parentAlias to parentRef as alias
		tell application "Finder"
			try
				return folder childName of folder parentAlias
			on error
				return make new folder at folder parentAlias with properties {name:childName}
			end try
		end tell
	on error errMsg
		display alert "フォルダ作成エラー" message errMsg as warning
		return missing value
	end try
end getOrCreateFolder

```
</details>