清单格式:两种写法(软件名 / 手写目录)都支持 :+ 追加与 :- 排除;名录改用对象数组并逐条介绍目录
需求
- 支持两种条目写法:1) 直接写软件名录里的软件名;2) 用户手写目录。
- 两种写法都必须支持追加(:+)与排除(:-)。
- 软件名录要改进:scoop 合并成"一个软件 + 一个目录数组"。
- 运行时要把"分别是哪些目录、每个目录是干什么的、排除/追加的理由"讲清楚。
实现
- 名录(SoftwareCatalog.psd1 / Get-SoftwareCatalog)
* 一个软件挂多个目录时写成**对象数组**:@{ Path = '...'; Description = '...' };
也接受纯字符串数组与旧的 @{ Dirs = ... } / @{ Variants = ... }。
* 目录说明(Description)一路带到运行日志里。
* "声明了但当前不存在"的目录不再被丢掉:备份跳过,恢复仍然知道它该回到哪个位置。
* scoop 合并成一个数组条目(%UserProfile%\scoop\persist + %UserProfile%\.config\scoop);
ScoopApps-persist 保持独立条目 —— 它和前者末级名同为 persist,并进同一个归档会在包里撞名。
- 解析与解析结果(ConvertFrom-BackupListLine / Resolve-BackupEntry)
* `:+` 以前只对"软件名且能解析出目录"的写法生效,**手写目录的 :+ 会被整段丢掉**;
现在统一生效,且 :+ 后面写软件名会按名录展开。
* 行尾 `# 说明` 解析成 Comment,运行时打印。
- 归档与恢复
* 同一条目里两个同名目录:打包前明确报错(退出码 1),不再静默混成一棵树。
(7z 命令行没有"入库改名"的能力,归档内顶层名只能是文件系统上的那个名字。)
* 恢复时每个源只解出**它自己那棵子树**,不会再往别的父目录里复制兄弟目录。
- 可解释性
* 新增 Write-BackupEntryPlan:打包前打印条目的目录(含来源与介绍)以及排除/追加的出处;
Restore.ps1 同样打印"哪棵子树还原到哪、会新建还是覆盖"。
- 孤儿归档审计修正:判据只看当前清单,不再把 manifest 的历史记录当成"已知"。
否则"条目被合并/改名后留下的旧归档"会被历史记录遮住,永远不会报警。
验证
- Pester 84 项、零依赖单元 49 项、端到端 23 项,全部通过。
- 真实机器:合并后的 scoop.7z 233 MB / 29478 项 / 7z t 通过,manifest.roots=[persist|scoop];
恢复演练 26982/26982 逐字节一致(.ssh、legendary、Aria 同批通过)。
- 迁移提醒:scoop-config.7z 与 scoop-persist.7z 已无清单条目指向,会出现在孤儿审计里;
确认 scoop.7z 无误后可以自行删除。
This commit is contained in:
1 parent
e114cae8c8
commit
43fa4e52dd
10 files changed
+1083
-339
No files matched your search
+303
-214
@@ -392,6 +392,17 @@ function ConvertFrom-BackupListLine {
|
||||
return $null
|
||||
}
|
||||
|
||||
# 行内注释:`#` 前面有空白时,它后面整段是"这条为什么这么配"的说明。
|
||||
# 解析时摘出来单独放在 Comment 里,运行时打印,让人一眼看懂排除/追加的理由。
|
||||
# (路径里的 `#` 必须紧贴前一个字符,所以 `C:\a#b` 不会受影响。)
|
||||
$comment = $null
|
||||
$commentIndex = $content.IndexOf(' #')
|
||||
if ($commentIndex -ge 0) {
|
||||
$comment = $content.Substring($commentIndex + 1).Trim().TrimStart('#').Trim()
|
||||
$content = $content.Substring(0, $commentIndex).Trim()
|
||||
if ([string]::IsNullOrEmpty($content)) { return $null }
|
||||
}
|
||||
|
||||
# 行内记号(都用到 `:`,因为 `:` 在 Windows 路径里只可能是盘符,
|
||||
# 而 `::` `:+` `:-` 都不可能出现在真实路径里,所以切分不受引号位置影响):
|
||||
# :: 排除模式(历史写法,等价于 :-)
|
||||
@@ -477,6 +488,7 @@ function ConvertFrom-BackupListLine {
|
||||
AddedPaths = $addedPaths
|
||||
ExcludePatterns = $excludes
|
||||
Flags = $flags
|
||||
Comment = $comment
|
||||
Raw = $Line
|
||||
}
|
||||
}
|
||||
@@ -656,105 +668,123 @@ function Get-SoftwareCatalog {
|
||||
|
||||
$entry = if ($data -is [System.Collections.IDictionary]) { $data[$key] } else { $data.$key }
|
||||
|
||||
# 一个软件可以对应**多个目录**,写成数组;数组元素两种都认:
|
||||
# * 对象(推荐):@{ Path = '<目录>'; Description = '<这个目录是干什么的>' }
|
||||
# * 纯字符串: '<目录>'
|
||||
# 也兼容字典写法:@{ Dirs = @(...) } / @{ Variants = @(...) } / @{ Path = '<目录>' }
|
||||
$rawPath = $null
|
||||
$dirList = @()
|
||||
$candidates = @()
|
||||
$entryDescription = $null
|
||||
|
||||
if ($entry -is [System.Collections.IDictionary]) {
|
||||
# Dirs = 一个软件包含的多个目录(推荐写法)
|
||||
# Variants = 同名目录出现在多个位置(旧写法,等价于 Dirs,保留兼容)
|
||||
if ($entry.Contains('Dirs')) { $dirList = @($entry['Dirs']) }
|
||||
elseif ($entry.Contains('Variants')) { $dirList = @($entry['Variants']) }
|
||||
foreach ($key in 'Description', 'Note', 'Desc') {
|
||||
if ($entry.Contains($key)) { $entryDescription = [string]$entry[$key]; break }
|
||||
}
|
||||
if ($entry.Contains('Dirs')) { $candidates = @($entry['Dirs']) }
|
||||
elseif ($entry.Contains('Variants')) { $candidates = @($entry['Variants']) }
|
||||
if ($entry.Contains('Path')) { $rawPath = [string]$entry['Path'] }
|
||||
} else {
|
||||
} elseif ($entry -is [string]) {
|
||||
$rawPath = $entry
|
||||
} elseif ($entry -is [System.Collections.IEnumerable]) {
|
||||
$candidates = @($entry)
|
||||
} elseif ($null -ne $entry) {
|
||||
$rawPath = [string]$entry
|
||||
}
|
||||
if (-not $rawPath -and $dirList.Count -eq 0) { continue }
|
||||
|
||||
$resolved = if ($rawPath) { [Environment]::ExpandEnvironmentVariables($rawPath) } else { $null }
|
||||
|
||||
# 多目录:逐个解析(每个都可以用前缀补全),任一解析不出来就整体 Unresolved
|
||||
if ($dirList.Count -gt 0) {
|
||||
$paths = @()
|
||||
$unresolved = @()
|
||||
foreach ($item in $dirList) {
|
||||
$candidate = [Environment]::ExpandEnvironmentVariables([string]$item)
|
||||
if (Test-Path -LiteralPath $candidate) {
|
||||
$paths += $candidate
|
||||
continue
|
||||
}
|
||||
$parent = Split-Path -Path $candidate -Parent
|
||||
$leaf = Split-Path -Path $candidate -Leaf
|
||||
$found = $null
|
||||
if ($parent -and $leaf -and (Test-Path -LiteralPath $parent)) {
|
||||
$found = @(Find-ChildDirectoryByName -Parent $parent -Name $leaf -MaxDepth $MaxDepth)
|
||||
}
|
||||
if ($found -and $found.Count -gt 0) {
|
||||
$paths += $found[0]
|
||||
Write-Log "名录:$name 的 $candidate -> $($found[0])(按前缀补全)" -Level DEBUG
|
||||
} else {
|
||||
$unresolved += $candidate
|
||||
}
|
||||
}
|
||||
|
||||
$kind = if ($paths.Count -eq 0) { 'Unresolved' } elseif ($unresolved.Count -gt 0) { 'Partial' } else { 'Multi' }
|
||||
if ($unresolved.Count -gt 0) {
|
||||
Write-Log ("名录:{0} 有 {1} 个目录找不到:{2}" -f $name, $unresolved.Count, ($unresolved -join ';')) -Level WARN
|
||||
}
|
||||
|
||||
$result[$name] = [pscustomobject]@{
|
||||
Name = $name
|
||||
Path = $rawPath
|
||||
ResolvedPath = $(if ($paths.Count -gt 0) { $paths[0] } else { $resolved })
|
||||
Variants = $paths
|
||||
Dirs = $paths
|
||||
Missing = $unresolved
|
||||
Kind = $kind
|
||||
Raw = $entry
|
||||
}
|
||||
Write-Log "名录:$name -> $($paths.Count) 个目录" -Level DEBUG
|
||||
continue
|
||||
# 单目录写法:包成对象,好让"目录说明"跟目录一起走下去
|
||||
if ($candidates.Count -eq 0 -and $rawPath) {
|
||||
$candidates = @([pscustomobject]@{ Path = $rawPath; Description = $entryDescription })
|
||||
}
|
||||
if ($candidates.Count -eq 0) { continue }
|
||||
|
||||
# 逐个候选目录解析。**声明了几个就记几个**,找不到的也留着:
|
||||
# 备份端按存在性跳过它们,恢复端要靠它们把内容还原回原位。
|
||||
$items = @()
|
||||
foreach ($candidate in $candidates) {
|
||||
$candidatePath = $null
|
||||
$candidateDescription = $null
|
||||
|
||||
if ($candidate -is [string]) {
|
||||
$candidatePath = $candidate
|
||||
} elseif ($candidate -is [System.Collections.IDictionary]) {
|
||||
foreach ($key in 'Path', 'Dir', 'Directory') {
|
||||
if ($candidate.Contains($key)) { $candidatePath = [string]$candidate[$key]; break }
|
||||
}
|
||||
foreach ($key in 'Description', 'Note', 'Desc', 'Reason', 'Why') {
|
||||
if ($candidate.Contains($key)) { $candidateDescription = [string]$candidate[$key]; break }
|
||||
}
|
||||
} elseif ($null -ne $candidate) {
|
||||
# JSON 里是对象、.psd1 里一般是哈希表,两种都认
|
||||
$names = @($candidate.PSObject.Properties.Name)
|
||||
foreach ($key in 'Path', 'Dir', 'Directory') {
|
||||
if ($names -contains $key) { $candidatePath = [string]$candidate.$key; break }
|
||||
}
|
||||
foreach ($key in 'Description', 'Note', 'Desc', 'Reason', 'Why') {
|
||||
if ($names -contains $key) { $candidateDescription = [string]$candidate.$key; break }
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($candidatePath)) { continue }
|
||||
$declared = ([Environment]::ExpandEnvironmentVariables([string]$candidatePath)).Trim()
|
||||
if (-not $declared) { continue }
|
||||
|
||||
if (Test-Path -LiteralPath $declared) {
|
||||
$items += [pscustomobject]@{ Declared = $declared; Resolved = $declared; Exists = $true; Suffixed = $false; Description = $candidateDescription }
|
||||
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
|
||||
$parent = Split-Path -Path $declared -Parent
|
||||
$leaf = Split-Path -Path $declared -Leaf
|
||||
$found = $null
|
||||
if ($parent -and (Test-Path -LiteralPath $parent)) {
|
||||
if ($parent -and $leaf -and (Test-Path -LiteralPath $parent)) {
|
||||
$found = @(Find-ChildDirectoryByName -Parent $parent -Name $leaf -MaxDepth $MaxDepth)
|
||||
if ($found.Count -gt 1) { $kind = 'Multi' } 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)
|
||||
Dirs = @($found)
|
||||
Missing = @()
|
||||
Kind = $kind
|
||||
Raw = $entry
|
||||
Suffixed = $true
|
||||
foreach ($match in $found) {
|
||||
$items += [pscustomobject]@{ Declared = $declared; Resolved = $match; Exists = $true; Suffixed = $true; Description = $candidateDescription }
|
||||
}
|
||||
Write-Log "名录:$name -> $resolved(按前缀补全)" -Level DEBUG
|
||||
continue
|
||||
Write-Log "名录:$name 的 $declared -> $($found -join '、')(按前缀补全)" -Level DEBUG
|
||||
} else {
|
||||
# 找不到也留着:恢复时这正是"要把数据放回去"的那个位置
|
||||
$items += [pscustomobject]@{ Declared = $declared; Resolved = $declared; Exists = $false; Suffixed = $false; Description = $candidateDescription }
|
||||
}
|
||||
}
|
||||
if ($items.Count -eq 0) { continue }
|
||||
|
||||
$existing = @($items | Where-Object { $_.Exists } | ForEach-Object { $_.Resolved })
|
||||
$missing = @($items | Where-Object { -not $_.Exists } | ForEach-Object { $_.Declared })
|
||||
$declaredList = @($items | ForEach-Object { $_.Declared })
|
||||
|
||||
$kind = if ($existing.Count -eq 0) { 'Unresolved' }
|
||||
elseif ($missing.Count -gt 0) { 'Partial' }
|
||||
elseif ($existing.Count -gt 1) { 'Multi' }
|
||||
else { 'Single' }
|
||||
|
||||
if ($missing.Count -gt 0) {
|
||||
# 多目录条目里少了一个目录值得告警(整包少了一块);
|
||||
# 单目录条目少目录是常规情况(软件没装),备份端会明确说"跳过: X,源路径不存在",
|
||||
# 这里降成 DEBUG,免得每个条目都刷两遍同样的警告。
|
||||
$missingText = "名录:{0} 有 {1} 个目录找不到:{2}" -f $name, $missing.Count, ($missing -join ';')
|
||||
if ($items.Count -gt 1) { Write-Log $missingText -Level WARN } else { Write-Log $missingText -Level DEBUG }
|
||||
}
|
||||
Write-Log ("名录:{0} -> 声明 {1} 个目录,其中存在 {2} 个" -f $name, $items.Count, $existing.Count) -Level DEBUG
|
||||
|
||||
$result[$name] = [pscustomobject]@{
|
||||
Name = $name
|
||||
Path = $rawPath
|
||||
ResolvedPath = $resolved
|
||||
Variants = @()
|
||||
Dirs = $(if ($kind -eq 'Single') { @($resolved) } else { @() })
|
||||
Missing = @()
|
||||
# Path 保留"名录里写的那个字符串"。数组写法没有唯一字符串,取第一个候选项。
|
||||
Path = $(if ($rawPath) { $rawPath } else { $declaredList[0] })
|
||||
ResolvedPath = $(if ($existing.Count -gt 0) { $existing[0] } else { $missing[0] })
|
||||
Variants = $existing
|
||||
Dirs = $existing
|
||||
Declared = $declaredList
|
||||
Items = $items
|
||||
Missing = $missing
|
||||
Description = $entryDescription
|
||||
Kind = $kind
|
||||
Raw = $entry
|
||||
Suffixed = [bool](@($items | Where-Object { $_.Suffixed }).Count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,6 +909,46 @@ function Get-ItemArchiveName {
|
||||
return Get-BackupBaseName -RawPath $Entry.Path
|
||||
}
|
||||
|
||||
function New-BackupSourceItem {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
把一条路径整理成"要打包的一个源",并带上给人看的说明。
|
||||
|
||||
.DESCRIPTION
|
||||
返回 @{ RootName; ParentDir; RelativePaths; SourcePath; Description; Origin },
|
||||
拆不出父目录或末级名时返回 $null(相对路径、盘符根目录之类)。
|
||||
|
||||
归档里的布局是"以 ParentDir 为工作目录、把 RelativePaths 加进去",
|
||||
所以 RelativePaths 既是**文件系统上的名字**,也是**归档里的顶层名字**。
|
||||
7z 命令行没有"入库时改名"的能力,这两个名字只能是同一个 —— 因此
|
||||
Resolve-BackupEntry 会拦下"同一条目里两个同名目录"的情况。
|
||||
|
||||
Origin 说明这个源是怎么来的(catalog / path / append-catalog / append-path),
|
||||
运行时会打印出来,方便回答"这个目录为什么会被备份"。
|
||||
#>
|
||||
param(
|
||||
[string]$Path,
|
||||
$RootName = $null,
|
||||
$Description = $null,
|
||||
[string]$Origin = 'catalog'
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
|
||||
|
||||
$parent = Split-Path -Path $Path -Parent
|
||||
$leaf = Split-Path -Path $Path -Leaf
|
||||
if (-not $parent -or -not $leaf) { return $null }
|
||||
|
||||
return [pscustomobject]@{
|
||||
RootName = $RootName
|
||||
ParentDir = $parent
|
||||
RelativePaths = @($leaf)
|
||||
SourcePath = $Path
|
||||
Description = $Description
|
||||
Origin = $Origin
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-BackupEntry {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
@@ -910,158 +980,177 @@ function Resolve-BackupEntry {
|
||||
$baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth
|
||||
$rootNames = @($Entry.Flags | Where-Object { $_ -like 'root=*' } | ForEach-Object { $_.Substring(5) })
|
||||
|
||||
# 必须在下面任何一个 return 之前算出来:名录里没有这个名字时也要走
|
||||
# "路径不存在" 分支,那条分支引用 $rootName。原先它写在后面,靠 PowerShell
|
||||
# 的隐式 $null 侥幸不报错,但会把**外层作用域**残留的 $rootName 带进来。
|
||||
# 必须在下面任何一个分支之前算出来:名录里没有这个名字时也要用它,
|
||||
# 否则会读到调用方作用域里残留的 $rootName(PowerShell 是动态作用域)。
|
||||
$rootName = if ($rootNames.Count -gt 0) { $rootNames[0] } else { $baseName }
|
||||
|
||||
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]
|
||||
|
||||
# @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 之前),这里不再重复。
|
||||
|
||||
$catalogEntry = $null
|
||||
$sources = @()
|
||||
$archiveFlavor = 'name'
|
||||
$errorText = $null
|
||||
|
||||
if ($catalogEntry.Kind -in @('Multi', 'Partial')) {
|
||||
# 一个软件包含多个目录:每个目录各占归档里的一个根目录层。
|
||||
# 归档内层用**源目录自己的名字**(不用软件名),这样恢复时
|
||||
# 每个目录都能各自找到父目录与末级名,互不干扰。
|
||||
foreach ($dir in $catalogEntry.Dirs) {
|
||||
$dirParent = Split-Path -Path $dir -Parent
|
||||
$dirLeaf = Split-Path -Path $dir -Leaf
|
||||
if (-not $dirParent -or -not $dirLeaf) { continue }
|
||||
$sources += [pscustomobject]@{
|
||||
RootName = $null
|
||||
ParentDir = $dirParent
|
||||
RelativePaths = @($dirLeaf)
|
||||
SourcePath = $dir
|
||||
}
|
||||
if (-not $isName) {
|
||||
# ---- 写法二:用户手写的目录 / 文件(含 \ / 或 % 就按路径处理)----
|
||||
$archiveFlavor = 'path'
|
||||
$source = New-BackupSourceItem -Path ([Environment]::ExpandEnvironmentVariables($Entry.Path)) -Origin 'path'
|
||||
if ($source) { $sources += $source }
|
||||
}
|
||||
else {
|
||||
# ---- 写法一:软件名录里的软件名 ----
|
||||
$catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
|
||||
if (-not $catalog.ContainsKey($Entry.Path)) {
|
||||
$errorText = "软件名录里没有 '$($Entry.Path)'"
|
||||
}
|
||||
} 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)"
|
||||
else {
|
||||
$catalogEntry = $catalog[$Entry.Path]
|
||||
# 单目录条目的 RootName 沿用软件名(历史行为);多目录留空
|
||||
$catalogRootName = if ($catalogEntry.Kind -eq 'Single') { $rootName } else { $null }
|
||||
|
||||
if ($forcePathFlavor) {
|
||||
# @pathname:归档名走路径算法,包内布局按源目录名
|
||||
$archiveFlavor = 'path'
|
||||
$source = New-BackupSourceItem -Path $catalogEntry.ResolvedPath -Origin 'catalog'
|
||||
if ($source) { $sources += $source }
|
||||
if ($catalogEntry.Kind -eq 'Unresolved') { $errorText = "名录里的路径不存在:$($catalogEntry.Path)" }
|
||||
}
|
||||
else {
|
||||
# 名录里声明了几个目录就产出几个源 —— **当前不存在的那几个也要留着**:
|
||||
# 恢复时正是要靠它们把内容还原回原位;备份端按存在性自己跳过。
|
||||
foreach ($item in $catalogEntry.Items) {
|
||||
$source = New-BackupSourceItem -Path $item.Resolved -RootName $catalogRootName `
|
||||
-Description $item.Description -Origin 'catalog'
|
||||
if ($source) { $sources += $source }
|
||||
}
|
||||
if ($catalogEntry.Kind -eq 'Unresolved') {
|
||||
$errorText = "名录里的路径不存在:$($catalogEntry.Path)"
|
||||
} elseif ($catalogEntry.Kind -eq 'Partial') {
|
||||
$errorText = ("名录里有 {0} 个目录当前不存在,备份会跳过它们:{1}" -f `
|
||||
$catalogEntry.Missing.Count, ($catalogEntry.Missing -join ';'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 清单里的 `:+ <路径>` 追加项,叠加在名录的目录之后
|
||||
if ($Entry.AddedPaths -and $Entry.AddedPaths.Count -gt 0) {
|
||||
foreach ($added in $Entry.AddedPaths) {
|
||||
$addedPath = [Environment]::ExpandEnvironmentVariables($added)
|
||||
$addedParent = Split-Path -Path $addedPath -Parent
|
||||
$addedLeaf = Split-Path -Path $addedPath -Leaf
|
||||
if (-not $addedParent -or -not $addedLeaf) { continue }
|
||||
$sources += [pscustomobject]@{
|
||||
RootName = $null
|
||||
ParentDir = $addedParent
|
||||
RelativePaths = @($addedLeaf)
|
||||
SourcePath = $addedPath
|
||||
# ------------------------------------------------------------------
|
||||
# 追加段:`:+ <路径 或 软件名>`,**两种写法都生效**
|
||||
# 以前这一节只接在"软件名且能解析出目录"的那条路径后面,手写路径的 :+ 会被整段丢掉。
|
||||
# ------------------------------------------------------------------
|
||||
foreach ($added in @($Entry.AddedPaths)) {
|
||||
if ([string]::IsNullOrWhiteSpace([string]$added)) { continue }
|
||||
$addedText = ([string]$added).Trim()
|
||||
$addedPath = [Environment]::ExpandEnvironmentVariables($addedText)
|
||||
|
||||
if (-not (Test-LiteralPath -Path $addedPath)) {
|
||||
# 写得像软件名:按名录展开成它的全部目录
|
||||
$addedCatalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
|
||||
if ($addedCatalog.ContainsKey($addedPath)) {
|
||||
foreach ($item in $addedCatalog[$addedPath].Items) {
|
||||
$source = New-BackupSourceItem -Path $item.Resolved -Description $item.Description -Origin 'append-catalog'
|
||||
if ($source) { $sources += $source }
|
||||
}
|
||||
continue
|
||||
}
|
||||
Write-Log "追加项 '$addedText' 既不是字面路径,也不在软件名录里,已忽略" -Level WARN
|
||||
continue
|
||||
}
|
||||
|
||||
$source = New-BackupSourceItem -Path $addedPath -Origin 'append-path'
|
||||
if ($source) { $sources += $source }
|
||||
else { Write-Log "追加项无法拆出父目录与末级名,已忽略:$addedText" -Level WARN }
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 归档内顶层同名冲突拦截
|
||||
# 7z 加进来的路径,在归档里就是**文件系统上的那个名字**(命令行没有"入库改名"的能力)。
|
||||
# 所以同一个条目里出现两个同名目录(例如两个 persist)时,它们在包内会混成一棵树,
|
||||
# 解出来两边的内容都是错的。宁可明确报错,也不要静默搅在一起。
|
||||
# ------------------------------------------------------------------
|
||||
$seenTop = @{}
|
||||
$collisions = @()
|
||||
foreach ($source in $sources) {
|
||||
$top = @($source.RelativePaths)[0]
|
||||
if (-not $top) { continue }
|
||||
if ($seenTop.ContainsKey($top)) {
|
||||
$collisions += ("'{0}'({1} 与 {2})" -f $top, $seenTop[$top], $source.SourcePath)
|
||||
} else {
|
||||
$seenTop[$top] = $source.SourcePath
|
||||
}
|
||||
}
|
||||
|
||||
$blocking = $null
|
||||
if ($collisions.Count -gt 0) {
|
||||
$blocking = ("归档内顶层同名,无法区分:{0}。7z 不能把同一个源在包内改名,它们会在归档里混成一棵树;" +
|
||||
"请把它们拆成两个独立条目(各自一个归档)。") -f ($collisions -join ';')
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
IsName = $true
|
||||
IsName = [bool]$isName
|
||||
CatalogEntry = $catalogEntry
|
||||
BaseName = $baseName
|
||||
ArchiveFlavor = 'name'
|
||||
RootName = $rootName
|
||||
Sources = $sources
|
||||
ArchiveFlavor = $archiveFlavor
|
||||
RootName = $(if ($isName -and -not $forcePathFlavor) { $rootName } else { $null })
|
||||
Sources = @($sources)
|
||||
Source = $Entry.Path
|
||||
Error = $errorText
|
||||
Blocking = $blocking
|
||||
}
|
||||
}
|
||||
|
||||
function Write-BackupEntryPlan {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
在动手打包之前,把"这个条目会打包哪些目录、排除了什么、为什么"打印出来。
|
||||
|
||||
.DESCRIPTION
|
||||
目录说明来自 SoftwareCatalog;排除 / 追加的**来源**来自清单,逐项打印:
|
||||
* 每个目录一行:路径、它是怎么来的(名录 / 手写路径 / :+ 追加)、
|
||||
当前在不在、以及这个目录是干什么的(Description);
|
||||
* 排除模式按来源分组打印:清单的 :- 段、BackupConfig.psd1 的 DefaultExcludes;
|
||||
* 清单行尾的 `# 说明` 作为这条目的整体说明打印出来。
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$Resolved,
|
||||
[Parameter(Mandatory = $true)][string]$DisplayPath,
|
||||
[string[]]$ListExcludes = @(),
|
||||
[string[]]$ConfigExcludes = @(),
|
||||
[string]$Comment
|
||||
)
|
||||
|
||||
$originText = @{
|
||||
'catalog' = '软件名录'
|
||||
'path' = '手写路径'
|
||||
'append-catalog' = '清单 :+ 追加(按软件名录展开)'
|
||||
'append-path' = '清单 :+ 追加(字面路径)'
|
||||
}
|
||||
|
||||
Write-Log ("条目:{0}" -f $DisplayPath)
|
||||
Write-Log (" 归档:{0}" -f $Resolved.BaseName)
|
||||
if ($Comment) { Write-Log (" 说明:{0}" -f $Comment) }
|
||||
if ($Resolved.Error) { Write-Log (" 提示:{0}" -f $Resolved.Error) -Level WARN }
|
||||
|
||||
$sources = @($Resolved.Sources)
|
||||
if ($sources.Count -eq 0) {
|
||||
Write-Log ' 目录:没有解析出任何目录' -Level WARN
|
||||
}
|
||||
|
||||
for ($index = 0; $index -lt $sources.Count; $index++) {
|
||||
$source = $sources[$index]
|
||||
$exists = Test-Path -LiteralPath $source.SourcePath
|
||||
$origin = if ($source.Origin -and $originText.ContainsKey($source.Origin)) { $originText[$source.Origin] } else { $source.Origin }
|
||||
|
||||
Write-Log (" 目录 {0}/{1}:{2}" -f ($index + 1), $sources.Count, $source.SourcePath)
|
||||
Write-Log (" 来源:{0};{1}" -f $origin, $(if ($exists) { '存在,会打包' } else { '当前不存在,本次跳过' }))
|
||||
if ($source.Description) { Write-Log (" 介绍:{0}" -f $source.Description) }
|
||||
}
|
||||
|
||||
if ($ListExcludes.Count -gt 0) {
|
||||
Write-Log (" 排除 {0} 条(来自清单的 :- 段):{1}" -f $ListExcludes.Count, ($ListExcludes -join '、'))
|
||||
}
|
||||
if ($ConfigExcludes.Count -gt 0) {
|
||||
Write-Log (" 排除 {0} 条(来自 BackupConfig.psd1 的 DefaultExcludes):{1}" -f $ConfigExcludes.Count, ($ConfigExcludes -join '、'))
|
||||
}
|
||||
if ($ListExcludes.Count -eq 0 -and $ConfigExcludes.Count -eq 0) {
|
||||
Write-Log ' 排除:无(整包收下)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1397,7 +1486,7 @@ Export-ModuleMember -Function @(
|
||||
'ConvertFrom-BackupListLine', 'Get-ArchiveExcludeArgument', 'Test-LiteralPath',
|
||||
'Resolve-CatalogPath', 'Get-SoftwareCatalog', 'Find-ChildDirectoryByName', 'Format-CatalogName',
|
||||
'Get-ArchiveTopLevelNames',
|
||||
'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath',
|
||||
'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Write-BackupEntryPlan', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath',
|
||||
'Get-FolderSummary',
|
||||
'Read-BaknretManifest', 'Write-BaknretManifest', 'Move-BaknretArchiveIntoPlace',
|
||||
'Get-BaknretConfig', 'Get-BaknretPassword'
|
||||
|
||||
Reference in new issue
Block a user