引入软件名录:清单写软件名,归档名也用软件名

新功能
- 新增 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:
Shuery committed 2026-09-21 20:55:19 +08:00
1 parent dbc0c00554
commit 045d51ac9c
9 files changed
+1330 -143

No files matched your search

+105 -22
View File
@@ -94,11 +94,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
$snapshotDir = Resolve-ConfigPath -Path $null -Default $script:Config.SnapshotDir
$catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot
$manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'backup'
Write-Log "日志文件:$logPath"
Write-Log "备份目录:$BackupDir"
Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
if (-not (Test-Administrator)) {
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
@@ -144,6 +146,7 @@ $showToolOutput = (-not $QuietTool) -and ($script:Config.ToolOutput -ne 'quiet')
$toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') }
$lines = Get-Content -LiteralPath $BackupListPath
$seenBaseNames = @{}
$processed = 0; $skipped = 0; $failed = 0; $planned = 0
$failures = @()
$freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir
@@ -175,6 +178,8 @@ function New-ItemRecord {
baseName = $BaseName
source = $Source
resolvedSource = $ResolvedSource
roots = @()
catalog = $null
archive = $null
action = $null
reason = $null
@@ -237,6 +242,7 @@ function Save-ItemRecord {
function Invoke-BackupItem {
param(
[string]$SourcePath,
[string[]]$RelativePaths,
[string]$ItemName,
[string]$ParentDir,
[string]$FinalPath,
@@ -249,11 +255,15 @@ function Invoke-BackupItem {
$tempPath = "$FinalPath.tmp$($tool.Extension)"
if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue }
# 排除模式用**源目录名**作前缀(归档里就是这个名字),
# 与 7z 的路径匹配语义一致;软件名条目的归档根目录是软件名,但源目录名仍在其下一层。
$excludeArgument = Get-ArchiveExcludeArgument -ItemName $ItemName -Patterns $ExcludePatterns
if ($excludeArgument.Count -gt 0) {
Write-Log ("排除 {0} 项:{1}" -f $excludeArgument.Count, ($excludeArgument -join ' ')) -Level DEBUG
}
$sourceListFile = $null
try {
if ($tool.Name -eq '7z') {
$optimized = Get-Optimized7zArgument -SourcePath $SourcePath -Level $script:Config.CompressionLevel
@@ -268,7 +278,8 @@ function Invoke-BackupItem {
}
$argument += $tempPath
$argument += $ItemName
foreach ($relative in $RelativePaths) { $argument += $relative }
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $ParentDir
# 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败
@@ -286,7 +297,8 @@ function Invoke-BackupItem {
$argument += "-p$password"
}
$argument += $tempPath
$argument += $ItemName
# RAR 没有 -spf,退回"直接打包源目录",归档根目录就是源目录名
foreach ($relative in $RelativePaths) { $argument += $relative }
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $ParentDir
if ($exitCode -ne 0) {
@@ -298,12 +310,8 @@ function Invoke-BackupItem {
if ($UseEncryption) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉 encrypt 标记' }
}
Push-Location $ParentDir
try {
Compress-Archive -Path $ItemName -DestinationPath $tempPath -CompressionLevel Optimal -Force
} finally {
Pop-Location
}
$fullPaths = @($RelativePaths | ForEach-Object { Join-Path $ParentDir $_ })
Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force
$warnings = $false
}
@@ -346,6 +354,10 @@ function Invoke-BackupItem {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
}
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "$_" }
} finally {
if ($sourceListFile -and (Test-Path -LiteralPath $sourceListFile)) {
Remove-Item -LiteralPath $sourceListFile -Force -ErrorAction SilentlyContinue
}
}
}
@@ -358,16 +370,18 @@ foreach ($line in $lines) {
if (-not $item) { continue }
$displayPath = $item.Path
$sourcePath = [Environment]::ExpandEnvironmentVariables($item.Path)
$baseName = Get-BackupBaseName -RawPath $item.Path
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
if (-not $baseName) {
$record = New-ItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $sourcePath -Phase 'parse'
Save-ItemRecord -Record $record -Action 'failed' -Reason '无法从路径生成归档名' | Out-Null
if (-not $resolved.BaseName) {
$record = New-ItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $displayPath -Phase 'parse'
Save-ItemRecord -Record $record -Action 'failed' -Reason '无法生成归档名' | Out-Null
$failed++; $failures += $displayPath
continue
}
$baseName = $resolved.BaseName
$sourcePath = [Environment]::ExpandEnvironmentVariables($displayPath)
if (-not (Test-ItemSelected -DisplayPath $displayPath -BaseName $baseName)) {
Write-Log "跳过(未选中): $displayPath" -Level DEBUG
continue
@@ -375,17 +389,75 @@ foreach ($line in $lines) {
$record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
$record.archive = $baseName + $tool.Extension
$record.roots = @($resolved.Sources | ForEach-Object { $_.RootName })
if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path }
$finalPath = Join-Path $BackupDir $record.archive
if (-not (Test-Path -LiteralPath $sourcePath)) {
Write-Log "跳过: $displayPath,路径不存在" -Level WARN
Save-ItemRecord -Record $record -Action 'missing-source' -Reason '源路径不存在' | Out-Null
# 备份列表里写重了会生成两个同名归档,互相覆盖 —— 直接报错,不猜。
if ($seenBaseNames.ContainsKey($baseName)) {
$reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖"
Write-Log "失败: $displayPath,$reason" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null
$failed++; $failures += $displayPath
continue
}
$seenBaseNames[$baseName] = $displayPath
# Sources 为空 = 解析不出任何源(名录里没这个软件名、或路径拆不出父/子级)。
# 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值,
# 但 Sources 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的
# 存在性检查统一处理。
if ($resolved.Sources.Count -eq 0) {
$reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' }
Write-Log "跳过: $displayPath,$reason" -Level WARN
Save-ItemRecord -Record $record -Action 'missing-source' -Reason $reason | Out-Null
$skipped++
continue
}
$parentDir = Split-Path -Path $sourcePath -Parent
$itemName = Split-Path -Path $sourcePath -Leaf
# 源存在性检查必须在 Get-FolderSummary / Get-Item 之前:
# 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。
# 注意不能用 Join-Path 探测:目标盘符不存在时它会直接抛异常。
$expectedRoots = 0
$missingRoots = @()
foreach ($source in $resolved.Sources) {
foreach ($relative in $source.RelativePaths) {
$expectedRoots++
$candidate = "$($source.ParentDir.TrimEnd('\'))\$relative"
if (-not (Test-Path -LiteralPath $candidate)) { $missingRoots += $candidate }
}
}
if ($missingRoots.Count -ge $expectedRoots) {
Write-Log "跳过: $displayPath,源路径不存在" -Level WARN
Save-ItemRecord -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + ($missingRoots -join ';')) | Out-Null
$skipped++
continue
}
if ($missingRoots.Count -gt 0) {
Write-Log ("警告: {0} 有 {1} 个源路径不存在,本次只备份存在的部分:{2}" -f $displayPath, $missingRoots.Count, ($missingRoots -join ';')) -Level WARN
}
# 归档里只放真实存在的源
$liveSources = @()
foreach ($source in $resolved.Sources) {
$live = @($source.RelativePaths | Where-Object { Test-Path -LiteralPath "$($source.ParentDir.TrimEnd('\'))\$_" })
if ($live.Count -gt 0) {
$liveSources += [pscustomobject]@{
RootName = $source.RootName
ParentDir = $source.ParentDir
RelativePaths = $live
SourcePath = $source.SourcePath
}
}
}
$primarySource = $liveSources[0].SourcePath
$parentDir = $liveSources[0].ParentDir
# 排除模式的前缀始终用**源目录名**(归档里就是这个层级)
$itemName = Split-Path -Path $primarySource -Leaf
if (-not $parentDir -or -not $itemName) {
Write-Log "跳过: $displayPath,无法处理根目录" -Level WARN
Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '无法拆出父目录或末级名' | Out-Null
@@ -393,19 +465,28 @@ foreach ($line in $lines) {
continue
}
$summary = Get-FolderSummary -FolderPath $sourcePath
$summary = Get-FolderSummary -FolderPath $primarySource
foreach ($source in $liveSources[1..($liveSources.Count - 1)]) {
$extra = Get-FolderSummary -FolderPath $source.SourcePath
$summary.FileCount += $extra.FileCount
$summary.TotalSize += $extra.TotalSize
if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) {
$summary.LatestModifiedTime = $extra.LatestModifiedTime
}
}
$record.sourceFiles = $summary.FileCount
$record.sourceBytes = $summary.TotalSize
$archiveExists = Test-Path -LiteralPath $finalPath
$archiveItem = if ($archiveExists) { Get-Item -LiteralPath $finalPath } else { $null }
Write-Log ("开始备份: {0}({1} 个文件,{2} MB)" -f $displayPath, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2))
Write-Log ("开始备份: {0} -> {1}({2} 个文件,{3} MB)" -f $displayPath, $record.archive, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2))
# 空目录时 Get-FolderSummary 拿不到任何条目,回退到源自身的修改时间
# (源路径上面已经确认存在,这里的 Get-Item 不会再抛异常)
$sourceLatest = $summary.LatestModifiedTime
if (-not $sourceLatest) {
$sourceLatest = (Get-Item -LiteralPath $sourcePath -Force).LastWriteTime
$sourceLatest = (Get-Item -LiteralPath $primarySource -Force).LastWriteTime
}
if (-not $Force -and $archiveItem -and $sourceLatest -and $sourceLatest -le $archiveItem.LastWriteTime) {
@@ -457,7 +538,9 @@ foreach ($line in $lines) {
}
}
$result = Invoke-BackupItem -SourcePath $sourcePath -ItemName $itemName -ParentDir $parentDir `
$firstSource = $liveSources[0]
$result = Invoke-BackupItem -SourcePath $firstSource.SourcePath -RelativePaths $firstSource.RelativePaths `
-ItemName $itemName -ParentDir $firstSource.ParentDir `
-FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption `
-ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings