選択したパスを変換して開く

メールや Teams チャットに書かれた古い共用サーバーパスを選択し、Ctrl+Alt+@ を押すと、CSV の置換ルールで古い文字列を新しい文字列へ変換して開く PowerShell 常駐ツールです。

使い方

  1. path-replacements.csv を編集します。

    Enabled,OldText,NewText,Description
    true,\\old-server\share\2024,\\new-server\share\2025,2024 から 2025 へ移行
    

    OldText に置換前の文字列、NewText に置換後の文字列を書きます。選択したパス内のどこに OldText があっても置換します。長い OldText が優先されるため、部門別などの細かいルールも追加できます。

  2. PowerShell でこのフォルダを開き、構成を確認します。

    powershell.exe -NoProfile -ExecutionPolicy Bypass -Sta -File .\PathOpener.ps1 -ValidateOnly
    
  3. 常駐起動します。

    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"
    
  4. メールや Teams 上で古いパスを選択し、Ctrl+Alt+@ を押します。

    選択文字列を一時的にクリップボードへコピーし、CSV に一致する文字列を置換して、存在する新しいパスを開きます。クリップボードの内容は処理後に元へ戻します。

Windows 起動時に自動常駐させる

次を実行すると、現在のユーザーのスタートアップにショートカットを作成します。

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

CSV の仕様

内容
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 を開きます。

補足

Install-PathOpenerStartup.ps1

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.'

PathOpener.ps1

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()
    }
}

path-replacements.csv

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