引入软件名录:清单写软件名,归档名也用软件名
新功能 - 新增 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
+105
-22
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user