引入软件名录:清单写软件名,归档名也用软件名
新功能 - 新增 SoftwareCatalog.psd1 —— "软件名 -> 目录"映射表,BackupList.txt 里 直接写软件名即可,归档名也就是软件名(FooClolor.7z), 不再是 FooClolor_from_C_+Programs.7z 这种由路径拼出来的名字。 - 三种写法可混用:软件名、字面路径(现有清单无需改写)、软件名 @pathname。 - 名录支持前缀补全(legendary -> legendary_2.0.4,只认 <名>_* / <名>-*)、 Variants(同名目录在多处)、Includes(分文件维护)。 - tools/Rename-Archives.ps1:存量归档重命名,默认试运行,逐份大小校验并重建 manifest。 - 归档名重复直接报错,不再静默互相覆盖。 两套测试全绿:单元 42 项、端到端 23 项(新增名录命名/解析/迁移用例)。 过程中修掉的缺陷 - Resolve-BackupEntry 里 @pathname 与 Unresolved 分支顺序错误, @pathname 会被静默吃掉(改名后仍用软件名)。 - 源目录被删除时解析器丢掉 Sources,导致恢复端把软件名当路径、 报 "Cannot bind argument to parameter 'Path' because it is an empty string"。 恢复的语义恰恰是"源不存在就要还原回去",现在 Sources 照旧给出。 - 源存在性检查曾被漏掉,Get-Item 对不存在路径抛异常会中断整轮备份; 且不能用 Join-Path 探测——目标盘符不存在时它会直接抛异常。 - 计划任务脚本外的 Caller 需要 -DryRun 才能验,已实跑确认。
This commit is contained in:
1 parent
dbc0c00554
commit
045d51ac9c
9 files changed
+1330
-143
No files matched your search
+85
-18
@@ -82,11 +82,13 @@ function Resolve-ConfigPath {
|
||||
|
||||
if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir }
|
||||
$logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir
|
||||
$catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot
|
||||
$manifestPath = Join-Path $BackupDir 'manifest.json'
|
||||
|
||||
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'restore'
|
||||
Write-Log "日志文件:$logPath"
|
||||
Write-Log "备份目录:$BackupDir"
|
||||
Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
|
||||
if ($WhatIfPreference) { Write-Log '试运行模式(-WhatIf / -DryRun):不会写入任何文件' -Level WARN }
|
||||
|
||||
if (-not (Test-Administrator)) {
|
||||
@@ -111,8 +113,7 @@ function Find-ArchiveByBaseName {
|
||||
#>
|
||||
param([string]$BaseName)
|
||||
|
||||
$candidate = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $SupportedFormats } |
|
||||
$candidate = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $SupportedFormats } |
|
||||
Select-Object -First 1
|
||||
return $candidate
|
||||
}
|
||||
@@ -143,6 +144,19 @@ function Get-ArchiveForEntry {
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-7zExecutable {
|
||||
<# .SYNOPSIS 定位 7z.exe(PATH 优先,其次是常见安装位置)。 #>
|
||||
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||
if (-not $sevenZip) {
|
||||
$candidates = @(
|
||||
(Join-Path $env:ProgramFiles '7-Zip\7z.exe'),
|
||||
(Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')
|
||||
)
|
||||
$sevenZip = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
|
||||
}
|
||||
return $sevenZip
|
||||
}
|
||||
|
||||
function Invoke-Extraction {
|
||||
param([object]$ArchiveFile, [string]$DestinationPath)
|
||||
|
||||
@@ -153,15 +167,9 @@ function Invoke-Extraction {
|
||||
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
|
||||
}
|
||||
|
||||
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||
if (-not $sevenZip) {
|
||||
$candidates = @(
|
||||
(Join-Path $env:ProgramFiles '7-Zip\7z.exe'),
|
||||
(Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')
|
||||
)
|
||||
$sevenZip = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
|
||||
}
|
||||
|
||||
# 归档布局与历史保持一致:根目录就是源目录名(软件名条目也一样,
|
||||
# 软件名只用于归档文件名),因此直接整包解压到目标的父目录即可。
|
||||
$sevenZip = Get-7zExecutable
|
||||
if ($sevenZip) {
|
||||
Write-Log '使用 7z 解压' -Level DEBUG
|
||||
$argument = @('x', '-bsp2', '-y', "-o$destParent")
|
||||
@@ -278,8 +286,8 @@ foreach ($line in $lines) {
|
||||
if (-not $item) { continue }
|
||||
|
||||
$displayPath = $item.Path
|
||||
$destPath = [Environment]::ExpandEnvironmentVariables($displayPath)
|
||||
$baseName = Get-BackupBaseName -RawPath $displayPath
|
||||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
|
||||
$baseName = $resolved.BaseName
|
||||
|
||||
if (-not $baseName) { $stats.skipped++; continue }
|
||||
if (-not (Test-EntrySelected -DisplayPath $displayPath -BaseName $baseName)) { continue }
|
||||
@@ -291,6 +299,29 @@ foreach ($line in $lines) {
|
||||
continue
|
||||
}
|
||||
|
||||
# 恢复目的地。清单条目可能带多个源(同名目录分散在多处),每个源各恢复各的。
|
||||
$targets = @()
|
||||
if ($resolved.Sources.Count -gt 0) {
|
||||
foreach ($source in $resolved.Sources) {
|
||||
$targets += [pscustomobject]@{ DestPath = $source.SourcePath }
|
||||
}
|
||||
} else {
|
||||
$targets += [pscustomobject]@{ DestPath = [Environment]::ExpandEnvironmentVariables($displayPath) }
|
||||
}
|
||||
|
||||
# 防御:解析不出目的地时明确失败,别把空字符串喂给 Split-Path/Test-Path
|
||||
# (那种报错是 "Cannot bind argument to parameter 'Path' because it is an empty string")
|
||||
$targets = @($targets | Where-Object { $_.DestPath -and -not [string]::IsNullOrWhiteSpace($_.DestPath) })
|
||||
if ($targets.Count -eq 0) {
|
||||
$reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何源路径)"
|
||||
Write-Log "失败: $displayPath,$reason" -Level ERROR
|
||||
$stats.failed++
|
||||
$failures += $displayPath
|
||||
continue
|
||||
}
|
||||
|
||||
$destPath = $targets[0].DestPath
|
||||
|
||||
$archiveFile = $found.File
|
||||
$referencedArchives += $archiveFile.BaseName
|
||||
|
||||
@@ -312,8 +343,17 @@ foreach ($line in $lines) {
|
||||
Write-Log "跳过校验(非 7z): $($archiveFile.Name)" -Level DEBUG
|
||||
continue
|
||||
}
|
||||
$verifyCode = Invoke-ExternalCommand -FilePath (Get-Command 7z | Select-Object -First 1 -ExpandProperty Source) `
|
||||
-ArgumentList @('t', '-bso0', '-bsp0', $archiveFile.FullName)
|
||||
$verifyTool = Get-7zExecutable
|
||||
if (-not $verifyTool) {
|
||||
Write-Log '未找到 7z,无法校验' -Level ERROR
|
||||
$stats.failed++
|
||||
$failures += $displayPath
|
||||
continue
|
||||
}
|
||||
$verifyArgument = @('t', '-bso0', '-bsp0')
|
||||
if ($password) { $verifyArgument += "-p$password" }
|
||||
$verifyArgument += $archiveFile.FullName
|
||||
$verifyCode = Invoke-ExternalCommand -FilePath $verifyTool -ArgumentList $verifyArgument
|
||||
if ($verifyCode -eq 0) {
|
||||
Write-Log "校验通过: $($archiveFile.Name)" -Level INFO
|
||||
$stats.verified++
|
||||
@@ -341,14 +381,41 @@ foreach ($line in $lines) {
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $PSCmdlet.ShouldProcess($destPath, "从 $($archiveFile.Name) 解压")) {
|
||||
Write-Log "[试运行] 将解压 $($archiveFile.Name) -> $(Split-Path -Path $destPath -Parent)" -Level INFO
|
||||
$plannedTargets = @($targets | Where-Object { $_.DestPath })
|
||||
foreach ($target in $plannedTargets) {
|
||||
if (Test-Path -LiteralPath $target.DestPath) { continue }
|
||||
# Split-Path -Parent 对根路径(如 "E:\")返回空串,此时无父目录可建
|
||||
$targetParent = Split-Path -Path $target.DestPath -Parent
|
||||
if ($targetParent) {
|
||||
Write-Log "提示: 目标不存在,将新建 $targetParent" -Level DEBUG
|
||||
} else {
|
||||
Write-Log "提示: 目标不存在,且没有可创建的父目录:$($target.DestPath)" -Level DEBUG
|
||||
}
|
||||
}
|
||||
|
||||
$shouldRun = $true
|
||||
foreach ($target in $plannedTargets) {
|
||||
if (-not $PSCmdlet.ShouldProcess($target.DestPath, "从 $($archiveFile.Name) 解压")) { $shouldRun = $false }
|
||||
}
|
||||
|
||||
if (-not $shouldRun) {
|
||||
foreach ($target in $plannedTargets) {
|
||||
Write-Log "[试运行] 将解压 $($archiveFile.Name) -> $($target.DestPath)" -Level INFO
|
||||
}
|
||||
$stats.planned++
|
||||
continue
|
||||
}
|
||||
|
||||
$restoreFailed = $false
|
||||
try {
|
||||
if (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $destPath) {
|
||||
foreach ($target in $plannedTargets) {
|
||||
if (-not (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $target.DestPath)) {
|
||||
$restoreFailed = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $restoreFailed) {
|
||||
$stats.restored++
|
||||
Write-Log "恢复成功: $baseName" -Level INFO
|
||||
|
||||
|
||||
Reference in new issue
Block a user