メールや Teams チャットに書かれた古い共用サーバーパスを選択し、Ctrl+Alt+@ を押すと、CSV の置換ルールで古い文字列を新しい文字列へ変換して開く PowerShell 常駐ツールです。
path-replacements.csv を編集します。
Enabled,OldText,NewText,Description
true,\\old-server\share\2024,\\new-server\share\2025,2024 から 2025 へ移行
OldText に置換前の文字列、NewText に置換後の文字列を書きます。選択したパス内のどこに OldText があっても置換します。長い OldText が優先されるため、部門別などの細かいルールも追加できます。
PowerShell でこのフォルダを開き、構成を確認します。
powershell.exe -NoProfile -ExecutionPolicy Bypass -Sta -File .\PathOpener.ps1 -ValidateOnly
常駐起動します。
powershell.exe -NoProfile -ExecutionPolicy Bypass -Sta -File .\PathOpener.ps1
置換ルールを共用サーバー上の CSV から読む場合は、UNC パスを -CsvPath で指定します。
powershell.exe -NoProfile -ExecutionPolicy Bypass -Sta -File .\PathOpener.ps1 -CsvPath "\\server\share\path-replacements.csv"
メールや Teams 上で古いパスを選択し、Ctrl+Alt+@ を押します。
選択文字列を一時的にクリップボードへコピーし、CSV に一致する文字列を置換して、存在する新しいパスを開きます。クリップボードの内容は処理後に元へ戻します。
次を実行すると、現在のユーザーのスタートアップにショートカットを作成します。
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Install-PathOpenerStartup.ps1
共用サーバー上の CSV を使って自動常駐させる場合:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Install-PathOpenerStartup.ps1 -CsvPath "\\server\share\path-replacements.csv"
解除する場合:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Install-PathOpenerStartup.ps1 -Uninstall
| 列 | 内容 |
|---|---|
Enabled |
true または空なら有効。false, 0, no, off, disabled は無効。 |
OldText |
置換前の文字列。例: \\old-server\share\2024 |
NewText |
置換後の文字列。例: \\new-server\share\2025 |
Description |
メモ。処理には使いません。 |
たとえば選択したパスが \\archive\links\\old-server\share\2024\A\report.xlsx で、CSV に \\old-server\share\2024 から \\new-server\share\2025 へのルールがある場合、位置に関係なく置換して \\archive\links\\new-server\share\2025\A\report.xlsx を開きます。
CSV はホットキーを押すたびに読み直すため、ルール変更後の再起動は不要です。
-CsvPath には \\server\share\path-replacements.csv のような UNC パスを指定できます。実行ユーザーに読み取り権限が必要です。
共用サーバーに接続できない状態でホットキーを押した場合は、タスクトレイ通知で CSV 読み込みエラーを表示します。
変換後のパスが存在しない場合は開かず、タスクトレイ通知でエラーを表示します。
日本語キーボードでは既定で Ctrl+Alt+@ です。別キーにしたい場合は -HotkeyChar を指定できます。
powershell.exe -NoProfile -ExecutionPolicy Bypass -Sta -File .\PathOpener.ps1 -HotkeyChar '#'
param(
[string]$CsvPath,
[switch]$Uninstall
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
if ($env:OS -ne 'Windows_NT') {
throw 'Startup shortcut installation works on Windows only.'
}
$startupFolder = [Environment]::GetFolderPath('Startup')
$shortcutPath = Join-Path $startupFolder 'PathOpener.lnk'
if ($Uninstall) {
if (Test-Path -LiteralPath $shortcutPath) {
Remove-Item -LiteralPath $shortcutPath
Write-Host "Removed startup shortcut: $shortcutPath"
}
else {
Write-Host "Startup shortcut was not found: $shortcutPath"
}
exit 0
}
$scriptPath = Join-Path $PSScriptRoot 'PathOpener.ps1'
if (-not (Test-Path -LiteralPath $scriptPath -PathType Leaf)) {
throw "PathOpener.ps1 was not found: $scriptPath"
}
$powerShellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
if (-not (Test-Path -LiteralPath $powerShellExe -PathType Leaf)) {
$powerShellExe = (Get-Command powershell.exe -ErrorAction Stop).Source
}
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($shortcutPath)
$shortcut.TargetPath = $powerShellExe
$arguments = '-NoProfile -ExecutionPolicy Bypass -Sta -WindowStyle Hidden -File "' + $scriptPath + '"'
if (-not [string]::IsNullOrWhiteSpace($CsvPath)) {
$csvPathFull = [System.IO.Path]::GetFullPath($CsvPath)
$arguments += ' -CsvPath "' + $csvPathFull + '"'
}
$shortcut.Arguments = $arguments
$shortcut.WorkingDirectory = $PSScriptRoot
$shortcut.Description = 'Start PathOpener hotkey listener'
$shortcut.IconLocation = $powerShellExe + ',0'
$shortcut.Save()
Write-Host "Created startup shortcut: $shortcutPath"
if (-not [string]::IsNullOrWhiteSpace($CsvPath)) {
Write-Host "Replacement CSV: $csvPathFull"
}
Write-Host 'PathOpener will start from the next Windows sign-in.'
param(
[string]$CsvPath = (Join-Path $PSScriptRoot 'path-replacements.csv'),
[string]$HotkeyChar = '@',
[int]$CopyDelayMs = 180,
[switch]$NoRestoreClipboard,
[switch]$ValidateOnly
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
if ($env:OS -ne 'Windows_NT') {
throw 'PathOpener works on Windows only.'
}
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$nativeSource = @"
using System;
using System.Runtime.InteropServices;
namespace PathOpener {
[StructLayout(LayoutKind.Sequential)]
public struct POINT {
public int x;
public int y;
}
[StructLayout(LayoutKind.Sequential)]
public struct MSG {
public IntPtr hwnd;
public uint message;
public UIntPtr wParam;
public IntPtr lParam;
public uint time;
public POINT pt;
}
public static class NativeMethods {
[DllImport("user32.dll", SetLastError = true)]
public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll", SetLastError = true)]
public static extern bool UnregisterHotKey(IntPtr hWnd, int id);
[DllImport("user32.dll", SetLastError = true)]
public static extern short VkKeyScanEx(char ch, IntPtr dwhkl);
[DllImport("user32.dll")]
public static extern IntPtr GetKeyboardLayout(uint idThread);
[DllImport("user32.dll", SetLastError = true)]
public static extern sbyte GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
public static extern bool TranslateMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
public static extern IntPtr DispatchMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
public static extern void PostQuitMessage(int nExitCode);
[DllImport("user32.dll")]
public static extern short GetAsyncKeyState(int vKey);
}
}
"@
Add-Type -TypeDefinition $nativeSource
$script:AppName = 'PathOpener'
$script:CsvPathFull = [System.IO.Path]::GetFullPath($CsvPath)
$script:CopyDelayMs = $CopyDelayMs
$script:NoRestoreClipboard = [bool]$NoRestoreClipboard
$script:HotkeyVirtualKey = 0
$script:NotifyIcon = $null
$script:Running = $true
function ConvertTo-NormalizedPathText {
param([Parameter(Mandatory = $true)][string]$PathText)
$value = $PathText -replace '[\r\n]+', ''
$value = $value.Trim()
$wrappers = @(
@('"', '"'),
@("'", "'"),
@('<', '>'),
@('[', ']'),
@('(', ')'),
@('{', '}')
)
$changed = $true
while ($changed -and $value.Length -gt 1) {
$changed = $false
foreach ($pair in $wrappers) {
if ($value.StartsWith($pair[0]) -and $value.EndsWith($pair[1])) {
$value = $value.Substring(1, $value.Length - 2).Trim()
$changed = $true
}
}
}
$uri = $null
if ([System.Uri]::TryCreate($value, [System.UriKind]::Absolute, [ref]$uri) -and $uri.Scheme -eq 'file') {
$value = [System.Uri]::UnescapeDataString($uri.LocalPath)
}
$value = [Environment]::ExpandEnvironmentVariables($value)
$value = $value -replace '/', '\'
$value = $value.Trim()
while ($value.Length -gt 1 -and $value.EndsWith('\') -and ($value -notmatch '^[A-Za-z]:\\$')) {
$value = $value.Substring(0, $value.Length - 1)
}
return $value
}
function Get-RuleEnabled {
param($Row)
$property = $Row.PSObject.Properties['Enabled']
if ($null -eq $property) {
return $true
}
$value = [string]$property.Value
if ([string]::IsNullOrWhiteSpace($value)) {
return $true
}
return ($value.Trim() -notmatch '^(0|false|no|off|disabled)$')
}
function Get-CsvFieldValue {
param(
[Parameter(Mandatory = $true)]$Row,
[Parameter(Mandatory = $true)][string[]]$Names
)
foreach ($name in $Names) {
$property = $Row.PSObject.Properties[$name]
if ($null -ne $property) {
return [string]$property.Value
}
}
return $null
}
function Get-ReplacementRules {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Replacement CSV was not found: $Path"
}
$rows = @(Import-Csv -LiteralPath $Path -Encoding UTF8)
$rules = New-Object System.Collections.Generic.List[object]
$lineNumber = 1
foreach ($row in $rows) {
$lineNumber++
if (-not (Get-RuleEnabled -Row $row)) {
continue
}
$oldText = Get-CsvFieldValue -Row $row -Names @('OldText', 'Old', 'OldPrefix')
$newText = Get-CsvFieldValue -Row $row -Names @('NewText', 'New', 'NewPrefix')
if ([string]::IsNullOrWhiteSpace($oldText) -or [string]::IsNullOrWhiteSpace($newText)) {
throw "CSV line $lineNumber needs OldText and NewText."
}
$oldNormalized = ConvertTo-NormalizedPathText -PathText $oldText
$newNormalized = ConvertTo-NormalizedPathText -PathText $newText
if ([string]::IsNullOrWhiteSpace($oldNormalized) -or [string]::IsNullOrWhiteSpace($newNormalized)) {
throw "CSV line $lineNumber contains an empty replacement text."
}
$description = ''
if ($null -ne $row.PSObject.Properties['Description']) {
$description = [string]$row.Description
}
[void]$rules.Add([pscustomobject]@{
OldText = $oldNormalized
NewText = $newNormalized
Description = $description
LineNumber = $lineNumber
})
}
return @($rules | Sort-Object @{ Expression = { $_.OldText.Length }; Descending = $true })
}
function Resolve-Hotkey {
param([Parameter(Mandatory = $true)][string]$Character)
if ($Character.Length -ne 1) {
throw 'HotkeyChar must be exactly one character.'
}
$layout = [PathOpener.NativeMethods]::GetKeyboardLayout(0)
$scan = [int][PathOpener.NativeMethods]::VkKeyScanEx($Character[0], $layout)
if (($scan -band 0xFF) -eq 0xFF) {
throw "Cannot resolve hotkey character for this keyboard layout: $Character"
}
$vk = [uint32]($scan -band 0xFF)
$shiftState = ($scan -shr 8) -band 0xFF
$modAlt = [uint32]0x0001
$modControl = [uint32]0x0002
$modShift = [uint32]0x0004
$modNoRepeat = [uint32]0x4000
$mods = $modAlt -bor $modControl -bor $modNoRepeat
$display = New-Object System.Collections.Generic.List[string]
[void]$display.Add('Ctrl')
[void]$display.Add('Alt')
if (($shiftState -band 1) -ne 0) {
$mods = $mods -bor $modShift
[void]$display.Add('Shift')
}
[void]$display.Add($Character)
return [pscustomobject]@{
VirtualKey = $vk
Modifiers = $mods
Display = ($display -join '+')
}
}
function Limit-Text {
param(
[string]$Text,
[int]$MaxLength = 220
)
if ($null -eq $Text) {
return ''
}
if ($Text.Length -le $MaxLength) {
return $Text
}
return ($Text.Substring(0, $MaxLength - 3) + '...')
}
function Show-Notice {
param(
[string]$Title,
[string]$Message,
[string]$Icon = 'Info'
)
$messageText = Limit-Text -Text $Message
if ($null -ne $script:NotifyIcon) {
$script:NotifyIcon.BalloonTipTitle = $Title
$script:NotifyIcon.BalloonTipText = $messageText
$script:NotifyIcon.BalloonTipIcon = [Enum]::Parse([System.Windows.Forms.ToolTipIcon], $Icon)
$script:NotifyIcon.ShowBalloonTip(4500)
}
else {
Write-Host "[$Title] $messageText"
}
}
function Invoke-ClipboardOperation {
param(
[Parameter(Mandatory = $true)][scriptblock]$Operation,
[int]$Attempts = 12,
[int]$DelayMs = 60
)
$lastError = $null
for ($i = 0; $i -lt $Attempts; $i++) {
try {
return & $Operation
}
catch {
$lastError = $_
Start-Sleep -Milliseconds $DelayMs
}
}
throw $lastError
}
function Replace-TextOrdinalIgnoreCase {
param(
[Parameter(Mandatory = $true)][string]$Text,
[Parameter(Mandatory = $true)][string]$OldText,
[Parameter(Mandatory = $true)][string]$NewText
)
$builder = New-Object System.Text.StringBuilder
$position = 0
while ($position -lt $Text.Length) {
$index = $Text.IndexOf($OldText, $position, [System.StringComparison]::OrdinalIgnoreCase)
if ($index -lt 0) {
[void]$builder.Append($Text.Substring($position))
break
}
if ($index -gt $position) {
[void]$builder.Append($Text.Substring($position, $index - $position))
}
[void]$builder.Append($NewText)
$position = $index + $OldText.Length
}
if ($position -eq $Text.Length) {
return $builder.ToString()
}
if ($Text.Length -eq 0) {
return $Text
}
return $builder.ToString()
}
function Wait-HotkeyReleased {
param(
[uint32]$VirtualKey,
[int]$TimeoutMs = 900
)
$deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMs)
do {
$ctrlDown = ([PathOpener.NativeMethods]::GetAsyncKeyState(0x11) -band 0x8000) -ne 0
$altDown = ([PathOpener.NativeMethods]::GetAsyncKeyState(0x12) -band 0x8000) -ne 0
$keyDown = ([PathOpener.NativeMethods]::GetAsyncKeyState([int]$VirtualKey) -band 0x8000) -ne 0
if (-not ($ctrlDown -or $altDown -or $keyDown)) {
return
}
Start-Sleep -Milliseconds 25
} while ([DateTime]::UtcNow -lt $deadline)
}
function Get-SelectedText {
if ([System.Threading.Thread]::CurrentThread.ApartmentState -ne [System.Threading.ApartmentState]::STA) {
throw 'Run this script with powershell.exe -Sta or pwsh.exe -Sta.'
}
Wait-HotkeyReleased -VirtualKey $script:HotkeyVirtualKey
$oldData = $null
if (-not $script:NoRestoreClipboard) {
$oldData = Invoke-ClipboardOperation -Operation { [System.Windows.Forms.Clipboard]::GetDataObject() }
}
$marker = 'PATHOPENER-CLIPBOARD-' + [Guid]::NewGuid().ToString('N')
Invoke-ClipboardOperation -Operation { [System.Windows.Forms.Clipboard]::SetText($marker) }
try {
Start-Sleep -Milliseconds $script:CopyDelayMs
[System.Windows.Forms.SendKeys]::SendWait('^c')
$deadline = [DateTime]::UtcNow.AddMilliseconds(1000)
do {
Start-Sleep -Milliseconds 50
$text = Invoke-ClipboardOperation -Operation {
if ([System.Windows.Forms.Clipboard]::ContainsText()) {
return [System.Windows.Forms.Clipboard]::GetText()
}
return $null
} -Attempts 3
if ($null -ne $text -and $text -ne $marker) {
return $text
}
} while ([DateTime]::UtcNow -lt $deadline)
return $null
}
finally {
if (-not $script:NoRestoreClipboard) {
Invoke-ClipboardOperation -Operation {
if ($null -ne $oldData) {
[System.Windows.Forms.Clipboard]::SetDataObject($oldData, $true)
}
else {
[System.Windows.Forms.Clipboard]::Clear()
}
} | Out-Null
}
}
}
function Convert-PathByRules {
param(
[Parameter(Mandatory = $true)][string]$SelectedText,
[Parameter(Mandatory = $true)][object[]]$Rules
)
$selectedPath = ConvertTo-NormalizedPathText -PathText $SelectedText
if ([string]::IsNullOrWhiteSpace($selectedPath)) {
throw 'Selected text was empty.'
}
foreach ($rule in $Rules) {
$oldText = [string]$rule.OldText
$index = $selectedPath.IndexOf($oldText, [System.StringComparison]::OrdinalIgnoreCase)
if ($index -ge 0) {
$converted = Replace-TextOrdinalIgnoreCase -Text $selectedPath -OldText $oldText -NewText ([string]$rule.NewText)
return [pscustomobject]@{
Original = $selectedPath
Converted = $converted
Rule = $rule
}
}
}
throw "No replacement rule matched: $selectedPath"
}
function Open-ConvertedPath {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Converted path was not found: $Path"
}
Invoke-Item -LiteralPath $Path
}
function Invoke-PathOpenFromSelection {
try {
$rules = @(Get-ReplacementRules -Path $script:CsvPathFull)
if ($rules.Count -eq 0) {
throw "No enabled replacement rules are configured in $script:CsvPathFull"
}
$selectedText = Get-SelectedText
if ([string]::IsNullOrWhiteSpace($selectedText)) {
throw 'Could not copy selected text. Select a path first, then press the hotkey.'
}
$result = Convert-PathByRules -SelectedText $selectedText -Rules $rules
Open-ConvertedPath -Path $result.Converted
Show-Notice -Title $script:AppName -Message ("Opened: " + $result.Converted) -Icon 'Info'
}
catch {
Show-Notice -Title $script:AppName -Message $_.Exception.Message -Icon 'Warning'
}
}
function New-PathOpenerNotifyIcon {
param(
[string]$HotkeyText,
[string]$CsvPath
)
$icon = New-Object System.Windows.Forms.NotifyIcon
$icon.Icon = [System.Drawing.SystemIcons]::Application
$icon.Text = 'PathOpener'
$icon.Visible = $true
$menu = New-Object System.Windows.Forms.ContextMenuStrip
$statusItem = New-Object System.Windows.Forms.ToolStripMenuItem
$statusItem.Text = "Running: $HotkeyText"
$statusItem.Enabled = $false
[void]$menu.Items.Add($statusItem)
$openCsvItem = New-Object System.Windows.Forms.ToolStripMenuItem
$openCsvItem.Text = 'Open replacement CSV'
$openCsvItem.Add_Click({
Start-Process -FilePath 'notepad.exe' -ArgumentList "`"$script:CsvPathFull`""
})
[void]$menu.Items.Add($openCsvItem)
$reloadItem = New-Object System.Windows.Forms.ToolStripMenuItem
$reloadItem.Text = 'Check CSV'
$reloadItem.Add_Click({
try {
$rules = @(Get-ReplacementRules -Path $script:CsvPathFull)
Show-Notice -Title $script:AppName -Message ("CSV OK. Enabled rules: " + $rules.Count) -Icon 'Info'
}
catch {
Show-Notice -Title $script:AppName -Message $_.Exception.Message -Icon 'Warning'
}
})
[void]$menu.Items.Add($reloadItem)
[void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
$exitItem = New-Object System.Windows.Forms.ToolStripMenuItem
$exitItem.Text = 'Exit'
$exitItem.Add_Click({
$script:Running = $false
[PathOpener.NativeMethods]::PostQuitMessage(0)
})
[void]$menu.Items.Add($exitItem)
$icon.ContextMenuStrip = $menu
return $icon
}
$hotkey = Resolve-Hotkey -Character $HotkeyChar
$script:HotkeyVirtualKey = $hotkey.VirtualKey
if ($ValidateOnly) {
$rules = @(Get-ReplacementRules -Path $script:CsvPathFull)
Write-Host "CSV: $script:CsvPathFull"
Write-Host "Enabled replacement rules: $($rules.Count)"
Write-Host ("Hotkey: {0} (VK=0x{1:X2}, modifiers=0x{2:X4})" -f $hotkey.Display, $hotkey.VirtualKey, $hotkey.Modifiers)
exit 0
}
$created = $false
$mutex = New-Object System.Threading.Mutex($true, 'Local\PathOpener.CtrlAltAt', [ref]$created)
if (-not $created) {
Write-Error 'PathOpener is already running for this Windows user.'
exit 1
}
$hotkeyId = 0x504F
$wmHotkey = 0x0312
$registered = $false
try {
$script:NotifyIcon = New-PathOpenerNotifyIcon -HotkeyText $hotkey.Display -CsvPath $script:CsvPathFull
$registered = [PathOpener.NativeMethods]::RegisterHotKey([IntPtr]::Zero, $hotkeyId, $hotkey.Modifiers, $hotkey.VirtualKey)
if (-not $registered) {
$errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
throw "Could not register hotkey $($hotkey.Display). Windows error: $errorCode"
}
Show-Notice -Title $script:AppName -Message ("Running. Select a path and press " + $hotkey.Display + ".") -Icon 'Info'
Write-Host ("PathOpener is running. Hotkey: {0}. Press Ctrl+C or use the tray icon to exit." -f $hotkey.Display)
$msg = New-Object PathOpener.MSG
while ($script:Running) {
$messageResult = [PathOpener.NativeMethods]::GetMessage([ref]$msg, [IntPtr]::Zero, 0, 0)
if ($messageResult -eq 0) {
break
}
if ($messageResult -eq -1) {
$errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
throw "GetMessage failed. Windows error: $errorCode"
}
if ($msg.message -eq $wmHotkey -and $msg.wParam.ToUInt32() -eq $hotkeyId) {
Invoke-PathOpenFromSelection
}
[void][PathOpener.NativeMethods]::TranslateMessage([ref]$msg)
[void][PathOpener.NativeMethods]::DispatchMessage([ref]$msg)
}
}
finally {
if ($registered) {
[void][PathOpener.NativeMethods]::UnregisterHotKey([IntPtr]::Zero, $hotkeyId)
}
if ($null -ne $script:NotifyIcon) {
$script:NotifyIcon.Visible = $false
$script:NotifyIcon.Dispose()
}
if ($null -ne $mutex) {
$mutex.ReleaseMutex()
$mutex.Dispose()
}
}
Enabled,OldPrefix,NewPrefix,Description
false,\\old-server\share\2024,\\new-server\share\2025,Sample rule. Change this row and set Enabled to true.
ture,oneedrive,onedrive