diff --git a/README.md b/README.md index 7eb66df..fa7ac97 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,13 @@ The script auto-searches these locations in order: | `-CredentialFile` | No | — | Path to saved credential XML (see below) | | `-KeyFilePath` | No | — | Path to SSH private key (`.ppk`) | | `-SshHostKeyFingerprint` | No | — | SSH host key fingerprint for verification | -| `-RenamePattern` | No | — | Regex pattern to match in filename for renaming before upload | +| `-RenamePattern` | No | — | Regex pattern to match in filename for renaming on the **remote** side before upload | | `-RenameReplacement` | No | — | Replacement string for `-RenamePattern` (supports capture groups like `$1`) | +| `-ArchivePath` | No | — | Move successfully uploaded files to this local folder (created if missing). Cannot combine with `-DeleteAfterTransfer` | +| `-LocalRenamePattern` | No | — | Regex pattern to rename the **local** file after upload (in place, or into `-ArchivePath`) | +| `-LocalRenameReplacement` | No | — | Replacement string for `-LocalRenamePattern` (supports capture groups like `$1`) | | `-Recurse` | No | `false` | Scan subdirectories | -| `-DeleteAfterTransfer` | No | `false` | Delete local files after successful upload | +| `-DeleteAfterTransfer` | No | `false` | Delete local files after successful upload. Cannot combine with `-ArchivePath` | | `-DryRun` | No | `false` | Preview transfers without uploading | | `-LogFile` | No | — | Path to log file (logs to console if omitted) | | `-WinScpDllPath` | No | — | Explicit path to `WinSCPnet.dll` | @@ -127,7 +130,35 @@ The `-FileFilter` parameter uses PowerShell regex (case-insensitive by default). -Recurse -FileFilter '\.pdf$' ``` -## Renaming Files Before Upload +## Local Archive & Rename After Upload + +After a successful upload you can archive or rename the local source file. These are mutually exclusive with `-DeleteAfterTransfer`. + +### Archive to a folder +```powershell +.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "uploader" ` + -ArchivePath "C:\exports\sent" +``` + +### Archive and rename while archiving (e.g. add `_sent` before extension) +```powershell +.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "uploader" ` + -ArchivePath "C:\exports\sent" ` + -LocalRenamePattern '^(.+?)(\.[^.]+)$' -LocalRenameReplacement '${1}_sent${2}' +``` + +### Rename local file in place (no archive, no delete) +```powershell +.\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "uploader" ` + -LocalRenamePattern '^' -LocalRenameReplacement 'done_' +``` + +> `-LocalRenamePattern`/`-LocalRenameReplacement` only rename the local file — the remote name is controlled by `-RenamePattern`/`-RenameReplacement`. You can use both together. + +## Renaming Files on the Remote Side Use `-RenamePattern` (regex) and `-RenameReplacement` together to rename files on the remote side without touching the local files. diff --git a/Send-FilesToSftp.ps1 b/Send-FilesToSftp.ps1 index dcaa74d..f93f610 100644 --- a/Send-FilesToSftp.ps1 +++ b/Send-FilesToSftp.ps1 @@ -52,8 +52,22 @@ Replacement string for the -RenamePattern match. Supports regex capture groups ($1, ${1}, etc.). Use '$0' to reference the full match. +.PARAMETER ArchivePath + Move successfully uploaded files to this local folder instead of leaving them in place. + The folder will be created if it does not exist. + Cannot be combined with -DeleteAfterTransfer. + +.PARAMETER LocalRenamePattern + Regex pattern to match in the filename when renaming the local file after a successful upload. + Must be used with -LocalRenameReplacement. + Works standalone (rename in place) or with -ArchivePath (rename while archiving). + +.PARAMETER LocalRenameReplacement + Replacement string for -LocalRenamePattern. Supports regex capture groups ($1, ${1}, etc.). + .PARAMETER DeleteAfterTransfer Delete local files after successful upload (move behavior). + Cannot be combined with -ArchivePath. .PARAMETER DryRun Show what would be transferred without actually uploading. @@ -81,6 +95,25 @@ -HostName "sftp.example.com" -UserName "uploader" ` -RenamePattern '^' -RenameReplacement 'processed_' +.EXAMPLE + # Archive files to a local folder after upload + .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "uploader" ` + -ArchivePath "C:\exports\sent" + +.EXAMPLE + # Archive and rename locally (append date) after upload + .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "uploader" ` + -ArchivePath "C:\exports\sent" ` + -LocalRenamePattern '^(.+?)(\.[^.]+)$' -LocalRenameReplacement "`$1_sent`$2" + +.EXAMPLE + # Rename local file in place after upload (no archive) + .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` + -HostName "sftp.example.com" -UserName "uploader" ` + -LocalRenamePattern '^' -LocalRenameReplacement 'done_' + .EXAMPLE # Unattended with saved credentials and move behavior .\Send-FilesToSftp.ps1 -LocalPath "C:\exports" -RemotePath "/incoming" ` @@ -131,6 +164,12 @@ param( [string]$RenameReplacement, + [string]$ArchivePath, + + [string]$LocalRenamePattern, + + [string]$LocalRenameReplacement, + [switch]$Recurse, [switch]$DeleteAfterTransfer, @@ -194,18 +233,34 @@ function Find-WinScpDll { # ── Main ───────────────────────────────────────────────────────────────────── try { - # ── Validate rename parameters ─────────────────────────────────────── + # ── Validate parameters ────────────────────────────────────────────── if (($RenamePattern -and -not $RenameReplacement) -or ($RenameReplacement -and -not $RenamePattern)) { Write-Log "-RenamePattern and -RenameReplacement must be used together." -Level ERROR exit 1 } + if (($LocalRenamePattern -and -not $LocalRenameReplacement) -or ($LocalRenameReplacement -and -not $LocalRenamePattern)) { + Write-Log "-LocalRenamePattern and -LocalRenameReplacement must be used together." -Level ERROR + exit 1 + } + if ($ArchivePath -and $DeleteAfterTransfer) { + Write-Log "-ArchivePath and -DeleteAfterTransfer cannot be used together." -Level ERROR + exit 1 + } + + # ── Create archive folder if needed ───────────────────────────────── + if ($ArchivePath -and -not (Test-Path $ArchivePath)) { + New-Item -ItemType Directory -Path $ArchivePath -Force | Out-Null + Write-Log "Created archive folder: $ArchivePath" + } Write-Log "═══ SFTP Transfer Starting ═══" Write-Log "Local path : $LocalPath" Write-Log "Remote path : $RemotePath" Write-Log "File filter : $FileFilter" Write-Log "Host : ${HostName}:${Port}" - if ($RenamePattern) { Write-Log "Rename : '$RenamePattern' → '$RenameReplacement'" } + if ($RenamePattern) { Write-Log "Remote rename : '$RenamePattern' → '$RenameReplacement'" } + if ($LocalRenamePattern) { Write-Log "Local rename : '$LocalRenamePattern' → '$LocalRenameReplacement'" } + if ($ArchivePath) { Write-Log "Archive to : $ArchivePath" } if ($DryRun) { Write-Log "*** DRY RUN MODE - No files will be transferred ***" -Level WARN } # ── Find and load WinSCP ───────────────────────────────────────────── @@ -260,13 +315,26 @@ try { if ($DryRun) { Write-Log "Files that would be transferred:" -Level INFO foreach ($f in $allFiles) { - $destName = if ($RenamePattern) { $f.Name -replace $RenamePattern, $RenameReplacement } else { $f.Name } - $relativePath = $f.FullName.Substring($LocalPath.TrimEnd('\').Length) - $remoteDir = ($RemotePath.TrimEnd('/') + ($f.DirectoryName.Substring($LocalPath.TrimEnd('\').Length) -replace '\\', '/')) - $remoteDest = "$remoteDir/$destName" - $sizeKB = [math]::Round($f.Length / 1KB, 1) - $renameNote = if ($RenamePattern -and $destName -ne $f.Name) { " [renamed from $($f.Name)]" } else { '' } - Write-Log " $($f.FullName) → $remoteDest (${sizeKB} KB)$renameNote" + $destName = if ($RenamePattern) { $f.Name -replace $RenamePattern, $RenameReplacement } else { $f.Name } + $localFinalName = if ($LocalRenamePattern) { $f.Name -replace $LocalRenamePattern, $LocalRenameReplacement } else { $f.Name } + $remoteDir = $RemotePath.TrimEnd('/') + ($f.DirectoryName.Substring($LocalPath.TrimEnd('\').Length) -replace '\\', '/') + $remoteDest = "$remoteDir/$destName" + $sizeKB = [math]::Round($f.Length / 1KB, 1) + $remoteRenameNote = if ($RenamePattern -and $destName -ne $f.Name) { " [remote name: $destName]" } else { '' } + Write-Log " UPLOAD : $($f.FullName) → $remoteDest (${sizeKB} KB)$remoteRenameNote" + + if ($DeleteAfterTransfer) { + Write-Log " LOCAL : DELETE $($f.FullName)" + } + elseif ($ArchivePath) { + $archiveDest = Join-Path $ArchivePath $localFinalName + $localNote = if ($LocalRenamePattern -and $localFinalName -ne $f.Name) { " [renamed from $($f.Name)]" } else { '' } + Write-Log " LOCAL : MOVE → $archiveDest$localNote" + } + elseif ($LocalRenamePattern -and $localFinalName -ne $f.Name) { + $renameDest = Join-Path $f.DirectoryName $localFinalName + Write-Log " LOCAL : RENAME → $renameDest" + } } Write-Log "DRY RUN complete. $($allFiles.Count) file(s) would be transferred." -Level SUCCESS exit 0 @@ -347,12 +415,42 @@ try { $transferOptions = New-Object WinSCP.TransferOptions $transferOptions.TransferMode = [WinSCP.TransferMode]::Binary - $result = $session.PutFiles($file.FullName, $targetPath, $DeleteAfterTransfer, $transferOptions) + # Always upload without auto-delete; handle local disposition ourselves + $result = $session.PutFiles($file.FullName, $targetPath, $false, $transferOptions) $result.Check() $sizeKB = [math]::Round($file.Length / 1KB, 1) Write-Log "Transferred: $($file.Name) → $targetPath (${sizeKB} KB)" -Level SUCCESS $successCount++ + + # ── Local post-transfer disposition ────────────────────── + $localFinalName = if ($LocalRenamePattern) { $file.Name -replace $LocalRenamePattern, $LocalRenameReplacement } else { $file.Name } + + if ($DeleteAfterTransfer) { + Remove-Item -LiteralPath $file.FullName -Force + Write-Log "Deleted local: $($file.FullName)" + } + elseif ($ArchivePath) { + # Preserve subdirectory structure when recursing + $archiveDir = if ($Recurse -and $relativePath) { + $sub = $file.DirectoryName.Substring($LocalPath.TrimEnd('\').Length).TrimStart('\') + Join-Path $ArchivePath $sub + } else { $ArchivePath } + + if (-not (Test-Path $archiveDir)) { + New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null + } + + $archiveDest = Join-Path $archiveDir $localFinalName + Move-Item -LiteralPath $file.FullName -Destination $archiveDest -Force + $archiveNote = if ($localFinalName -ne $file.Name) { " (renamed from $($file.Name))" } else { '' } + Write-Log "Archived: $($file.FullName) → $archiveDest$archiveNote" + } + elseif ($LocalRenamePattern -and $localFinalName -ne $file.Name) { + $renameDest = Join-Path $file.DirectoryName $localFinalName + Rename-Item -LiteralPath $file.FullName -NewName $localFinalName -Force + Write-Log "Renamed local: $($file.Name) → $localFinalName" + } } catch { Write-Log "FAILED: $($file.Name) — $($_.Exception.Message)" -Level ERROR @@ -373,6 +471,9 @@ try { if ($DeleteAfterTransfer) { Write-Log " Mode : MOVE (source files deleted on success)" } + elseif ($ArchivePath) { + Write-Log " Mode : ARCHIVE → $ArchivePath" + } else { Write-Log " Mode : COPY (source files retained)" }