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

新功能
- 新增 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

+439 -8
View File
@@ -417,16 +417,15 @@ function ConvertFrom-BackupListLine {
# 从尾部摘出 @标记。没有 `::` 时标记直接跟在路径后面
# (如 `%UserProfile%\.ssh @encrypt`),因此 tail 为空时还要从路径那半再摘一次。
# 标记可能出现在任一侧,所以两边都要摘。
$tailSplit = Split-TrailingFlags -Text $tailPart
$flags = @($tailSplit.Flags)
$tailPart = $tailSplit.Remainder
if (-not $tailPart) {
$pathSplit = Split-TrailingFlags -Text $pathPart
if ($pathSplit.Flags.Count -gt 0) {
$flags = @($pathSplit.Flags) + $flags
$pathPart = $pathSplit.Remainder
}
$pathSplit = Split-TrailingFlags -Text $pathPart
if ($pathSplit.Flags.Count -gt 0) {
$flags = @($pathSplit.Flags) + $flags
$pathPart = $pathSplit.Remainder
}
$excludes = @()
@@ -436,6 +435,9 @@ function ConvertFrom-BackupListLine {
return [pscustomobject]@{
Path = $pathPart
# 目录名或文件名,需要靠 SoftwareCatalog 换成真实路径;
# 带分隔符或 %变量% 的写法按字面路径处理(并给出警告)。
IsName = (-not (Test-LiteralPath -Path $pathPart))
ExcludePatterns = $excludes
Flags = $flags
Raw = $Line
@@ -443,6 +445,23 @@ function ConvertFrom-BackupListLine {
}
}
function Test-LiteralPath {
<#
.SYNOPSIS
判断清单里的一行是不是"字面路径"(而非软件名)。
.DESCRIPTION
出现分隔符(\ 或 /)或 %环境变量% 就当作字面路径,其余按软件名去名录里查。
这条规则保证:现有的全路径清单不需要任何改写就能继续工作。
#>
param([AllowEmptyString()][string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $true }
if ($Path.Contains('\') -or $Path.Contains('/')) { return $true }
if ($Path.Contains('%')) { return $true }
return $false
}
function Get-ArchiveExcludeArgument {
<#
.SYNOPSIS
@@ -491,10 +510,418 @@ function Get-ArchiveExcludeArgument {
return ,$result
}
# ============================================================================
# 软件名录(SoftwareCatalog.psd1)
# ============================================================================
function Resolve-CatalogPath {
<#
.SYNOPSIS
计算软件名录的绝对路径(优先 .psd1,找不到就退而用 .json)。
#>
param([string]$Configured, [string]$Root)
$candidates = @()
if ($Configured) {
$value = $Configured
if (-not [System.IO.Path]::IsPathRooted($value)) { $value = Join-Path $Root $value }
$candidates += $value
}
$candidates += (Join-Path $Root 'SoftwareCatalog.psd1')
$candidates += (Join-Path $Root 'SoftwareCatalog.json')
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate) { return $candidate }
}
return $candidates[0]
}
function Format-CatalogName {
<#
.SYNOPSIS
把软件名规范化成合法的归档基础名。
.DESCRIPTION
软件名就是归档名,所以这里必须挡住非法文件名字符。
保留 & % +(与路径命名算法的白名单一致)。
#>
param([Parameter(Mandatory = $true)][string]$Name)
$invalidChars = [System.IO.Path]::GetInvalidFileNameChars() |
Where-Object { $_ -notin @('&', '%', '+') }
$clean = -join ($Name.Trim().ToCharArray() | ForEach-Object {
if ($_ -in $invalidChars) { '_' } else { $_ }
})
$clean = $clean -replace ':', '_'
return $clean.Trim()
}
function Get-SoftwareCatalog {
<#
.SYNOPSIS
载入"软件名 -> 目录"名录。
.DESCRIPTION
返回按名字索引的哈希表,每项是 @{ Name; Path; ResolvedPath; Kind; Raw }。
Kind 取值:Single(一个目录)| Variant(有 variants 的同名目录)| Unresolved(没找到目录)。
名录文件可以是 .psd1 或 .json——默认用 .psd1,因为路径这种东西很需要写注释。
.psd1 里可以用 `Includes` 键引入其它名录文件,多个游戏/多个盘的目录可以分文件维护。
#>
param(
[Parameter(Mandatory = $true)][string]$Path,
[int]$MaxDepth = 5
)
$result = @{}
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $result }
$data = $null
try {
if ($Path.ToLower().EndsWith('.json')) {
$data = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
} else {
$data = Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop
}
} catch {
Write-Log "软件名录读取失败:$Path —— $_" -Level ERROR
return $result
}
# 递归引入其它名录文件
if ($data -is [System.Collections.IDictionary] -and $data.Contains('Includes')) {
$includeList = @($data['Includes'])
$baseDir = Split-Path -Parent $Path
foreach ($include in $includeList) {
if (-not $include) { continue }
$includePath = [string]$include
if (-not [System.IO.Path]::IsPathRooted($includePath)) { $includePath = Join-Path $baseDir $includePath }
$included = Get-SoftwareCatalog -Path $includePath -MaxDepth $MaxDepth
foreach ($includedName in $included.Keys) {
if ($result.ContainsKey($includedName)) { continue }
$result[$includedName] = $included[$includedName]
}
}
}
# 顶层除 Includes 外的每个键都是一个软件名
$keys = @()
if ($data -is [System.Collections.IDictionary]) {
$keys = @($data.Keys | Where-Object { $_ -ne 'Includes' })
} else {
$keys = @($data.PSObject.Properties.Name | Where-Object { $_ -ne 'Includes' })
}
foreach ($key in $keys) {
$name = Format-CatalogName -Name ([string]$key)
if (-not $name) { continue }
$entry = if ($data -is [System.Collections.IDictionary]) { $data[$key] } else { $data.$key }
$rawPath = $null
$variants = $null
if ($entry -is [System.Collections.IDictionary]) {
if ($entry.Contains('Path')) { $rawPath = [string]$entry['Path'] }
if ($entry.Contains('Variants')) { $variants = @($entry['Variants']) }
} else {
$rawPath = [string]$entry
}
if (-not $rawPath) { continue }
$resolved = [Environment]::ExpandEnvironmentVariables($rawPath)
if ($variants -and $variants.Count -gt 0) {
# 同名目录出现在多个位置:不猜,所有位置都作为归档根目录。
$resolvedVariants = @($variants | ForEach-Object { [Environment]::ExpandEnvironmentVariables([string]$_) })
$result[$name] = [pscustomobject]@{
Name = $name
Path = $rawPath
ResolvedPath = $resolved
Variants = $resolvedVariants
Kind = 'Variant'
Raw = $entry
}
Write-Log "名录:$name 有 $($resolvedVariants.Count) 个候选位置" -Level DEBUG
continue
}
if (Test-Path -LiteralPath $resolved) {
$kind = 'Single'
} else {
# 名录里写的是父目录,实际目录带版本号之类后缀(如 legendary 的 <name>_2.0.4)
$parent = Split-Path -Path $resolved -Parent
$leaf = Split-Path -Path $resolved -Leaf
$found = $null
if ($parent -and (Test-Path -LiteralPath $parent)) {
$found = @(Find-ChildDirectoryByName -Parent $parent -Name $leaf -MaxDepth $MaxDepth)
if ($found.Count -gt 1) { $kind = 'Variant' } elseif ($found.Count -eq 1) { $kind = 'Single' } else { $kind = 'Unresolved' }
} else {
$kind = 'Unresolved'
}
if ($found -and $found.Count -gt 0) {
$resolved = $found[0]
$result[$name] = [pscustomobject]@{
Name = $name
Path = $rawPath
ResolvedPath = $resolved
Variants = @($found)
Kind = $kind
Raw = $entry
Suffixed = $true
}
Write-Log "名录:$name -> $resolved(按前缀补全)" -Level DEBUG
continue
}
}
$result[$name] = [pscustomobject]@{
Name = $name
Path = $rawPath
ResolvedPath = $resolved
Variants = @()
Kind = $kind
Raw = $entry
}
}
return $result
}
function Find-ChildDirectoryByName {
<#
.SYNOPSIS
在 $Parent 下按精确名或"<名>_<后缀>"/"<名>-<后缀>"形式找目录。
.DESCRIPTION
只做保守的前缀补全:必须以下一个字符是 _ 或 - 为界,
避免把 Legendary 匹配成 LegendarySomething。
#>
param(
[Parameter(Mandatory = $true)][string]$Parent,
[Parameter(Mandatory = $true)][string]$Name,
[int]$MaxDepth = 5
)
$escaped = [regex]::Escape($Name)
$pattern = "^$escaped(_|-).+"
try {
return @(Get-ChildItem -LiteralPath $Parent -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ieq $Name -or $_.Name -imatch $pattern } |
Sort-Object Name |
Select-Object -ExpandProperty FullName)
} catch {
return @()
}
}
# ============================================================================
# 归档命名与路径还原
# ============================================================================
function Get-ItemArchiveName {
<#
.SYNOPSIS
决定一个条目的归档基础名(不含扩展名)。
.DESCRIPTION
规则:
* 默认用**软件名**(看起来像软件名就查名录;名录里没有则退回可读的目录名);
* 条目带 `@pathname` 时用原来的路径命名算法;
* 条目本来就写的是字面路径(含分隔符或 %变量%)时也用路径命名算法,
这样现有清单不需要改写就能继续工作。
#>
param($Entry, [string]$CatalogPath, [int]$MaxDepth = 5)
# @pathname 时用"真实路径"跑路径命名算法。
# 清单里写的可能是软件名,必须先经名录换成真实路径,
# 否则 Get-BackupBaseName 会对软件名本身运算,得出错误的名字。
if ($Entry.Flags -contains 'pathname') {
$nameSource = $Entry.Path
if (-not (Test-LiteralPath -Path $Entry.Path)) {
$catalogForPath = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
if ($catalogForPath.ContainsKey($Entry.Path)) {
$nameSource = $catalogForPath[$Entry.Path].Path
}
}
return Get-BackupBaseName -RawPath $nameSource
}
$looksLikePath = Test-LiteralPath -Path $Entry.Path
if (-not $looksLikePath) {
$catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
if ($catalog.ContainsKey($Entry.Path)) {
return $catalog[$Entry.Path].Name
}
Write-Log "名录里没有 '$($Entry.Path)',按目录名处理" -Level WARN
return (Format-CatalogName -Name $Entry.Path)
}
return Get-BackupBaseName -RawPath $Entry.Path
}
function Resolve-BackupEntry {
<#
.SYNOPSIS
把清单条目解析成"实际要备份什么"。
.DESCRIPTION
返回 @{ IsName; BaseName; Sources; Source; ArchiveFlavor },其中:
* IsName —— 这一行写的是软件名还是字面路径
* BaseName —— 归档基础名
* Sources —— 要备份的项目列表(一个根目录名 -> 该根目录下的一组相对路径)
* ArchiveFlavor —— 'name'(归档根目录叫软件名)或 'path'(叫源目录名)
归档内部布局:
* 软件名条目 -> 根目录用软件名,内容为 `<源目录名>\...`
(这样恢复时能知道文件原来属于哪个目录)
* 字面路径条目 -> 整条目直接写进归档,保持与历史归档完全一致的布局,
否则现有归档一旦被重打,恢复就会失败。
#>
param(
$Entry,
[string]$CatalogPath,
[int]$MaxDepth = 5
)
$isName = -not (Test-LiteralPath -Path $Entry.Path)
# @pathname 强制按"字面路径条目"处理:归档名用路径算法,
# 但清单里写的是软件名,真实路径仍要经名录解析。
$forcePathFlavor = ($Entry.Flags -contains 'pathname')
$baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth
$rootNames = @($Entry.Flags | Where-Object { $_ -like 'root=*' } | ForEach-Object { $_.Substring(5) })
if (-not $isName) {
$sourcePath = [Environment]::ExpandEnvironmentVariables($Entry.Path)
return [pscustomobject]@{
IsName = $false
CatalogEntry = $null
BaseName = $baseName
ArchiveFlavor = 'path'
RootName = $null
Sources = @([pscustomobject]@{ RootName = $null; ParentDir = (Split-Path -Path $sourcePath -Parent); RelativePaths = @((Split-Path -Path $sourcePath -Leaf)); SourcePath = $sourcePath })
Source = $Entry.Path
}
}
$catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
if (-not $catalog.ContainsKey($Entry.Path)) {
return [pscustomobject]@{
IsName = $true
CatalogEntry = $null
BaseName = $baseName
ArchiveFlavor = 'name'
RootName = $rootName
Sources = @()
Source = $Entry.Path
Error = "软件名录里没有 '$($Entry.Path)'"
}
}
$catalogEntry = $catalog[$Entry.Path]
$rootName = if ($rootNames.Count -gt 0) { $rootNames[0] } else { $baseName }
# @pathname 必须排在 Unresolved 分支之前:否则路径不存在的条目会先被
# 当作普通软件名条目返回 Flavor='name',把 @pathname 覆盖悄悄吃掉。
if ($forcePathFlavor) {
$flavorPath = $catalogEntry.ResolvedPath
$flavorParent = Split-Path -Path $flavorPath -Parent
$flavorLeaf = Split-Path -Path $flavorPath -Leaf
$flavorSource = @()
if ($flavorParent -and $flavorLeaf) {
$flavorSource = @([pscustomobject]@{
RootName = $null
ParentDir = $flavorParent
RelativePaths = @($flavorLeaf)
SourcePath = $flavorPath
})
}
return [pscustomobject]@{
IsName = $true
CatalogEntry = $catalogEntry
BaseName = $baseName
ArchiveFlavor = 'path'
RootName = $null
Sources = $flavorSource
Source = $Entry.Path
Error = $(if ($catalogEntry.Kind -eq 'Unresolved') { "名录里的路径不存在:$($catalogEntry.Path)" } else { $null })
}
}
# 名录里的路径当前不存在。备份时这是"跳过",但**恢复时这正是要恢复的场景**,
# 所以 SourcePath 仍然给出来(恢复端会照它把内容还原回原位),
# 只把 Error 标出来让备份端跳过。
if ($catalogEntry.Kind -eq 'Unresolved') {
$missingPath = $catalogEntry.ResolvedPath
$missingParent = Split-Path -Path $missingPath -Parent
$missingLeaf = Split-Path -Path $missingPath -Leaf
$missingSource = @()
if ($missingParent -and $missingLeaf) {
$missingSource = @([pscustomobject]@{
RootName = $rootName
ParentDir = $missingParent
RelativePaths = @($missingLeaf)
SourcePath = $missingPath
})
}
return [pscustomobject]@{
IsName = $true
CatalogEntry = $catalogEntry
BaseName = $baseName
ArchiveFlavor = 'name'
RootName = $rootName
Sources = $missingSource
Source = $Entry.Path
Error = "名录里的路径不存在:$($catalogEntry.Path)"
}
}
# @pathname 已在前面统一处理(必须排在 Unresolved 之前),这里不再重复。
$sources = @()
if ($catalogEntry.Kind -eq 'Variant') {
$parent = Split-Path -Path $catalogEntry.ResolvedPath -Parent
$relatives = @($catalogEntry.Variants | ForEach-Object { Split-Path -Path $_ -Leaf })
$sources = @([pscustomobject]@{
RootName = $rootName
ParentDir = $parent
RelativePaths = $relatives
SourcePath = $parent
})
} elseif ($catalogEntry.Kind -eq 'Single') {
$sources = @([pscustomobject]@{
RootName = $rootName
ParentDir = (Split-Path -Path $catalogEntry.ResolvedPath -Parent)
RelativePaths = @((Split-Path -Path $catalogEntry.ResolvedPath -Leaf))
SourcePath = $catalogEntry.ResolvedPath
})
} else {
return [pscustomobject]@{
IsName = $true
CatalogEntry = $catalogEntry
BaseName = $baseName
ArchiveFlavor = 'name'
RootName = $rootName
Sources = @()
Source = $Entry.Path
Error = "名录里的路径不存在:$($catalogEntry.Path)"
}
}
return [pscustomobject]@{
IsName = $true
CatalogEntry = $catalogEntry
BaseName = $baseName
ArchiveFlavor = 'name'
RootName = $rootName
Sources = $sources
Source = $Entry.Path
}
}
function Get-BackupBaseName {
<#
.SYNOPSIS
@@ -728,6 +1155,8 @@ function Get-BaknretConfig {
BackupDir = 'Backups'
LogDir = 'logs'
SnapshotDir = 'Backups\snapshots'
SoftwareCatalog = 'SoftwareCatalog.psd1'
CatalogMaxDepth = 5
MinFreeSpaceGB = 8
VerifyArchive = $true
ComputeHash = $false
@@ -788,8 +1217,10 @@ Export-ModuleMember -Function @(
'Set-BaknretDebug', 'Start-BaknretLog', 'Stop-BaknretLog', 'Get-BaknretLogPath', 'Write-Log',
'Test-Administrator', 'Get-BaknretFreeSpaceGB',
'ConvertTo-NativeArgumentString', 'Invoke-ExternalCommand', 'Resolve-CompressionTool', 'Get-Optimized7zArgument',
'ConvertFrom-BackupListLine', 'Get-ArchiveExcludeArgument',
'Get-BackupBaseName', 'Convert-BackupFileNameToPath', 'Get-FolderSummary',
'ConvertFrom-BackupListLine', 'Get-ArchiveExcludeArgument', 'Test-LiteralPath',
'Resolve-CatalogPath', 'Get-SoftwareCatalog', 'Find-ChildDirectoryByName', 'Format-CatalogName',
'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath',
'Get-FolderSummary',
'Read-BaknretManifest', 'Write-BaknretManifest', 'Move-BaknretArchiveIntoPlace',
'Get-BaknretConfig', 'Get-BaknretPassword'
)