**REGION 00 初期値(フォームに最初から入る値。空なら空欄) # 初期_移動元 が空のときは <ユーザー>\Downloads\PDF-Results の中で一番新しい PAD_Hierarchy_… を自動で入れる # 初期_親フォルダー が空のときは <ユーザー>\Downloads\PDF-Results\振り分け # 初期_表示する子フォルダー : ID 一覧(HTML)で ID の下に並べる子フォルダー名(改行区切り)。移動先の子パスは自動で含む Variables.ConvertJsonToCustomObject Json: $'''{ "初期_移動元": "", "初期_親フォルダー": "", "初期_桁数": "8", "初期_子パス": "保管", "初期_ログも移動": "true", "初期_表示する子フォルダー": "保管\\n作業" }''' CustomObject=> Init **ENDREGION **REGION 01 文章(画面の文言を変えたいときはここ) Variables.ConvertJsonToCustomObject Json: $'''{ "画面_入力タイトル": "PDF振り分け", "画面_入力説明": "移動元直下の PDF を、ファイル名の先頭 N 桁を ID として「親フォルダー\\\\ID\\\\子パス」へ移動します。無いフォルダーは作ります。上書きはしません。", "画面_移動元": "移動元フォルダー(取得したPDFがある場所。初期値は一番新しい取得フォルダー)", "画面_親": "振り分け先の親フォルダー(この下に ID フォルダーを作る。無ければ作成)", "画面_桁数": "ID の桁数(ファイル名の先頭から。例:DEMO-001_… なら 8)", "画面_子パス": "ID フォルダーの中の子パス(任意。例:保管 や 保管\\\\2026年度)", "画面_ログも移動": "ID 別のダウンロード結果ログも一緒に移動する", "画面_開始": "振り分け開始", "画面_キャンセル": "キャンセル", "画面_結果タイトル": "PDF振り分け|結果", "画面_結果ログ欄": "振り分け結果(原本は移動元の 振り分け結果_日時.txt)", "画面_結果案内": "「ID一覧を開く」「親フォルダーを開く」は開くだけで、この画面は残ります。終わるときは「閉じる」。", "画面_ID一覧を開く": "ID一覧を開く(HTML)", "画面_ID一覧HTA": "ID一覧を開く(Explorer ワンクリック版)", "画面_親を開く": "親フォルダーを開く", "画面_再試行": "条件を変えてもう一度", "画面_閉じる": "閉じる", "画面_確認未了": "確認の項目にチェックを入れてください。" }''' CustomObject=> Msg **ENDREGION **REGION 02 入力 System.GetEnvironmentVariable.GetEnvironmentVariable Name: $'''USERPROFILE''' Value=> ProfileFolder SET SrcFolder TO Init['初期_移動元'] SET DstParent TO Init['初期_親フォルダー'] SET IdDigits TO Init['初期_桁数'] SET ChildPath TO Init['初期_子パス'] SET MoveLogs TO Init['初期_ログも移動'] SET ShowKids TO Init['初期_表示する子フォルダー'] IF IsEmpty(DstParent) THEN SET DstParent TO $'''%ProfileFolder%\\Downloads\\PDF-Results\\振り分け''' END IF IsEmpty(SrcFolder) THEN # 一番新しい取得フォルダーを探す Scripting.RunPowershellScript.RunScript Script: $'''$root = [IO.Path]::Combine("%ProfileFolder%", "Downloads", "PDF-Results") if ([IO.Directory]::Exists($root)) { $d = Get-ChildItem -LiteralPath $root -Directory -Filter "PAD_Hierarchy_*" | Sort-Object Name -Descending | Select-Object -First 1; if ($d) { $d.FullName } }''' ScriptOutput=> LatestOut ScriptError=> LatestErr ON ERROR GOTO LatestUnknown END SET SrcFolder TO LatestOut.Trimmed LABEL LatestUnknown END SET Notice TO Msg['画面_入力説明'] LABEL ShowInput Display.ShowCustomDialog CardTemplateJson: '''{ "type": "AdaptiveCard", "version": "1.4", "id": "AdaptiveCard", "body": [ { "type": "TextBlock", "id": "Notice", "text": "${Notice_Text}", "wrap": true }, { "type": "Input.Text", "id": "Src", "label": "${Src_Label}", "value": "${Src_Value}", "isRequired": true, "maxLength": 1024 }, { "type": "Input.Text", "id": "Dst", "label": "${Dst_Label}", "value": "${Dst_Value}", "isRequired": true, "maxLength": 1024 }, { "type": "Input.Text", "id": "Digits", "label": "${Digits_Label}", "value": "${Digits_Value}", "isRequired": true, "maxLength": 3 }, { "type": "Input.Text", "id": "Child", "label": "${Child_Label}", "value": "${Child_Value}", "maxLength": 256 }, { "type": "Input.Toggle", "id": "Logs", "title": "${Logs_Title}", "value": "${Logs_Value}", "valueOn": "true", "valueOff": "false" }, { "type": "Input.Toggle", "id": "ChkSrc", "title": "移動元が今回の取得フォルダーであることを確認した", "value": "false", "valueOn": "true", "valueOff": "false", "isRequired": true, "errorMessage": "チェックが必要です" } ], "actions": [ { "type": "Action.Submit", "id": "OK", "title": "${Start_Title}" }, { "type": "Action.Submit", "id": "Cancel", "title": "${Cancel_Title}", "associatedInputs": "none" } ], "FormTitle": "${Form_Title}" }''' CustomFormData=> InputData ButtonPressed=> InputButton @Notice_Text: Notice @Src_Value: SrcFolder @Dst_Value: DstParent @Digits_Value: IdDigits @Child_Value: ChildPath @Logs_Value: MoveLogs @Src_Label: Msg['画面_移動元'] @Dst_Label: Msg['画面_親'] @Digits_Label: Msg['画面_桁数'] @Child_Label: Msg['画面_子パス'] @Logs_Title: Msg['画面_ログも移動'] @Start_Title: Msg['画面_開始'] @Cancel_Title: Msg['画面_キャンセル'] @Form_Title: Msg['画面_入力タイトル'] ON ERROR GOTO Quit END IF InputButton <> $'''OK''' THEN EXIT Code: 0 END SET SrcFolder TO InputData['Src'] SET DstParent TO InputData['Dst'] SET IdDigits TO InputData['Digits'] SET ChildPath TO InputData['Child'] SET MoveLogs TO InputData['Logs'] IF InputData['ChkSrc'] <> $'''true''' THEN SET Notice TO $'''⚠ %Msg['画面_確認未了']%''' GOTO ShowInput END **ENDREGION **REGION 03 振り分け(PowerShell 1回) Variables.ConvertJsonToCustomObject Json: $'''{}''' CustomObject=> Carry SET Carry['src'] TO SrcFolder SET Carry['dst'] TO DstParent SET Carry['digits'] TO IdDigits SET Carry['child'] TO ChildPath SET Carry['logs'] TO MoveLogs SET Carry['show'] TO ShowKids Variables.ConvertCustomObjectToJson CustomObject: Carry Json=> CarryJson File.GetTempPath TempFile=> CarryFile File.WriteText File: CarryFile TextToWrite: CarryJson AppendNewLine: False IfFileExists: File.IfFileExists.Overwrite Encoding: File.FileEncoding.UTF8 Scripting.RunPowershellScript.RunScript Script: $''' $ErrorActionPreference = "Stop" $carrier = "%CarryFile%" $r = @{ status = "failed"; summary = "振り分け結果を確認できません。"; log = ""; logPath = ""; listHtml = ""; listHta = ""; listCsv = ""; parent = "" } $sep = [IO.Path]::DirectorySeparatorChar $linkCache = @{} function Fail([string]$status, [string]$msg) { $script:r.status = $status; $script:r.summary = $msg; throw "__stop__" } function Test-Name([string]$n) { if ($n.Length -eq 0 -or $n.Length -gt 255 -or $n -eq "." -or $n -eq "..") { return $false } if ($n -match "[\\x00-\\x1f<>:\\x22/\\\\|?*]" -or $n -match "[ .]$") { return $false } if (($n -split "[.]")[0].Trim() -match "^(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$") { return $false } return $true } function Assert-RealFolder([string]$path) { # シンボリックリンク・ジャンクションの下へは移動しない(OneDrive のクラウド属性は可)。フォルダーごとに1回だけ調べる $full = [IO.Path]::GetFullPath($path) if ($linkCache.ContainsKey($full)) { return } $cur = $full while ($cur) { if ($linkCache.ContainsKey($cur)) { break } $item = Get-Item -LiteralPath $cur -Force if ($item.LinkType) { throw "リンク($($item.LinkType))配下のため対象外:$cur" } $linkCache[$cur] = $true $cur = [IO.Path]::GetDirectoryName($cur) } } function Ensure-Folder([string]$parent, [string]$name) { if (-not (Test-Name $name)) { throw "フォルダー名に使えません:$name" } $p = [IO.Path]::Combine($parent, $name) if ([IO.File]::Exists($p)) { throw "同名のファイルがあります:$p" } if (-not [IO.Directory]::Exists($p)) { [IO.Directory]::CreateDirectory($p) | Out-Null } Assert-RealFolder $p return $p } try { $in = [IO.File]::ReadAllText($carrier, [Text.Encoding]::UTF8) | ConvertFrom-Json $src = [string]$in.src; $dst = [string]$in.dst; $child = ([string]$in.child).Trim().Replace("/", "\\") $includeLogs = ([string]$in.logs) -eq "true" $showKids = @(([string]$in.show) -split "[\\r\\n]+" | ForEach-Object { $_.Trim().Replace("/", "\\") } | Where-Object { $_.Length -gt 0 }) if (-not [IO.Path]::IsPathRooted($src) -or -not [IO.Directory]::Exists($src)) { Fail "invalid" "移動元フォルダーが見つかりません。絶対パスで指定してください。" } if (-not [IO.Path]::IsPathRooted($dst)) { Fail "invalid" "振り分け先の親フォルダーは絶対パスで指定してください。" } if (([string]$in.digits) -notmatch "^[0-9]{1,3}$" -or [int]$in.digits -lt 1 -or [int]$in.digits -gt 200) { Fail "invalid" "ID の桁数は 1~200 の整数で入力してください。" } $digits = [int]$in.digits $childParts = @(); if ($child.Length -gt 0) { $childParts = @($child.Split("\\") | ForEach-Object { $_.Trim() }) foreach ($part in $childParts) { if (-not (Test-Name $part)) { Fail "invalid" "子パスに使えない名前があります:$part" } } } $src = [IO.Path]::GetFullPath($src); $dst = [IO.Path]::GetFullPath($dst) $child = $childParts -join "\\" foreach ($sk in $showKids) { if ([IO.Path]::IsPathRooted($sk)) { Fail "invalid" "表示する子フォルダーは相対パスで指定してください。" } foreach ($part in $sk.Split("\\")) { if (-not (Test-Name $part)) { Fail "invalid" "表示する子フォルダーに使えない名前があります:$part" } } } Assert-RealFolder $src $ancestor = $dst while (-not [IO.Directory]::Exists($ancestor)) { if ([IO.File]::Exists($ancestor)) { Fail "invalid" "移動先の途中に同名ファイルがあります。" } $ancestor = [IO.Path]::GetDirectoryName($ancestor) if (-not $ancestor) { Fail "invalid" "移動先のドライブを確認してください。" } } Assert-RealFolder $ancestor if (-not [IO.Directory]::Exists($dst)) { [IO.Directory]::CreateDirectory($dst) | Out-Null } Assert-RealFolder $dst $logRegex = "_ダウンロード結果_[0-9]{8}_[0-9]{6}_[0-9]{3}[.]txt$" $targets = @(Get-ChildItem -LiteralPath $src -File -Force | Where-Object { $_.Extension -ieq ".pdf" -or ($includeLogs -and $_.Name -match $logRegex) } | Sort-Object Name) $stamp = Get-Date -Format "yyyyMMdd_HHmmss" $uniq = [IO.Path]::GetFileNameWithoutExtension([IO.Path]::GetRandomFileName()) $finalLog = [IO.Path]::Combine($src, "振り分け結果_" + $stamp + "_" + $uniq + ".txt") $tempLog = $finalLog + ".処理中.txt" if ([IO.File]::Exists($finalLog)) { Fail "failed" "同名の振り分け結果ログがあるため中止しました。移動していません。" } $w = [IO.StreamWriter]::new($tempLog, $false, [Text.UTF8Encoding]::new($true)) $lines = [Collections.Generic.List[string]]::new() function Put([string]$t) { $script:w.WriteLine($t); $script:lines.Add($t) } Put "[処理中・未確定] 完了ログではありません。" Put "" $header = @("[移動処理ログファイル]", $finalLog, "", "[移動条件]", "移動元フォルダ : $src", "移動先フォルダ(親): $dst", "移動先フォルダ(子): 各ファイル先頭 $digits 桁", ("移動先フォルダ(孫): " + $(if ($child) { $child } else { "(なし)" })), "ID別ログも振り分け: $includeLogs", "", "[ファイル別結果]") foreach ($h in $header) { Put $h }; $w.Flush() $ok = 0; $ng = 0; $idFolders = [ordered]@{} foreach ($f in $targets) { $name = $f.Name; $base = [IO.Path]::GetFileNameWithoutExtension($name); $id = ""; $to = ""; $why = "" try { if ($base.Length -lt $digits) { throw "ファイル名が ID の桁数より短い" } $id = $name.Substring(0, $digits) if (-not (Test-Name $id)) { throw "先頭 $digits 桁がフォルダー名として使えない($id)" } if (-not $idFolders.Contains($id)) { $planned = [IO.Path]::Combine($dst,$id) foreach ($part in $childParts) { $planned = [IO.Path]::Combine($planned,$part) } $idFolders[$id] = @{ path=$planned; ok=0; ng=0 } } $cur = Get-Item -LiteralPath $f.FullName -Force if ($cur.LinkType) { throw "元ファイルがリンク($($cur.LinkType))のため対象外" } if (($cur.Attributes -band 0x1000) -or ($cur.Attributes -band 0x400000)) { throw "オンラインのみのファイル(ローカルに取得してから再実行)" } if ($cur.Length -ne $f.Length) { throw "一覧確定後にサイズが変わった" } if (-not $idFolders[$id].ready) { $folder = Ensure-Folder $dst $id foreach ($part in $childParts) { $folder = Ensure-Folder $folder $part } $idFolders[$id].path = $folder; $idFolders[$id].ready = $true } $folder = $idFolders[$id].path $to = [IO.Path]::Combine($folder, $name) if ([IO.File]::Exists($to) -or [IO.Directory]::Exists($to)) { throw "移動先に同名があるため上書きしない" } [IO.File]::Move($f.FullName, $to) if ([IO.File]::Exists($f.FullName) -or -not [IO.File]::Exists($to)) { throw "移動後の所在を確認できない" } if ((Get-Item -LiteralPath $to).Length -ne $f.Length) { throw "移動後のサイズが一致しない(移動先を確認)" } $ok++; $idFolders[$id].ok++; $entry = @("【〇 成功】$id | $name") } catch { $ng++; $why = $_.Exception.Message if ($id -and $idFolders.Contains($id)) { $idFolders[$id].ng++ } $now = if ([IO.File]::Exists($f.FullName)) { $f.FullName } elseif ($to -and [IO.File]::Exists($to)) { $to } else { "不明" } $entry = @(("【X 失敗】" + $(if ($id) { $id } else { "ID抽出不能" }) + " | " + $name), " 理由=$why", " 現在=$now", (" 移動先=" + $(if ($to) { "予定:$to" } else { "未特定" }))) } try { foreach ($e in $entry) { Put $e }; $w.Flush() } catch { Fail "failed" ("ログ追記に失敗したため以後の移動を中止しました(" + $_.Exception.Message + ")。移動元・移動先と処理中ログを確認してください。") } } $summary = "対象" + $targets.Count + "件/成功" + $ok + "件/失敗" + $ng + "件" $w.Close(); $w = $null # 確定ログ:冒頭に件数を 1 回だけ。処理中ログの先頭 2 行(未確定の注記)は外す $final = @("[処理結果まとめ]", $summary, "") + @($lines | Select-Object -Skip 2) + @("", "[終了]", "") [IO.File]::WriteAllLines($finalLog, $final, [Text.UTF8Encoding]::new($true)) $r.logPath = $finalLog; $r.log = ($final -join "`r`n"); $r.parent = $dst [IO.File]::Delete($tempLog) $listCsv = [IO.Path]::Combine($dst, "ID一覧_" + $stamp + ".csv"); $listHtml = [IO.Path]::Combine($dst, "ID一覧_" + $stamp + ".html"); $listHta = [IO.Path]::Combine($dst, "ID一覧_" + $stamp + ".hta") function Enc([string]$t) { return [Net.WebUtility]::HtmlEncode($t) } function Kind([string]$ext) { if ($ext -ieq ".pdf") { return "pdf" } elseif ($ext -ieq ".txt" -or $ext -ieq ".csv" -or $ext -ieq ".md" -or $ext -ieq ".log") { return "txt" } else { return "other" } } # 1行:記号+名前+ボタン。data-p にパス。ボタンの中身は html/hta で差し替える function Row([string]$label, [string]$path, [string]$cls, [string]$note, [bool]$isDir) { $btn = $(if ($isDir) { "__DIRBTN__" } else { "__FILEBTN__" }) return "