引入软件名录:清单写软件名,归档名也用软件名
新功能 - 新增 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
+1326
-139
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
|
||||
|
||||
|
||||
+54
-44
@@ -1,37 +1,49 @@
|
||||
# BackupList.txt —— 备份 / 恢复共用清单
|
||||
#
|
||||
# 语法:
|
||||
# <路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
|
||||
# <软件名 或 路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
|
||||
#
|
||||
# 路径 :支持 %环境变量%;可用双引号包裹(引号只包路径);/ 与 \ 等价。
|
||||
# 排除模式 :相对归档根目录(也就是源目录的末级名)。
|
||||
# 以 ! 开头表示"任意层级下匹配这个组件名",翻译成 7z 的 -xr!。
|
||||
# 不要自己写引号;7z 的模式不支持空格,工具会自动把空格转成 ?。
|
||||
# 标记 :encrypt = 用 7z 加密这个归档(口令来自 BAKNRET_PASSWORD 或 -KeyFile)。
|
||||
# 三种写法都可以,混用也没问题:
|
||||
#
|
||||
# 注意:
|
||||
# * 归档名由 <末级名>_from_<上级路径用 + 连接> 生成。改动路径会生成新归档名,
|
||||
# 旧归档不会被自动迁移,恢复时请用 manifest.json 或保留原写法。
|
||||
# * 源路径不存在的条目会明确报告为「跳过: 路径不存在」,不再静默忽略。
|
||||
# * 对已经不存在、但归档还在的条目,保留在这里可以继续恢复出历史数据。
|
||||
# 1. 软件名(推荐)—— 去 SoftwareCatalog.psd1 里查目录,归档名就是软件名
|
||||
# FooClolor
|
||||
# Kazumi :: !*Cache
|
||||
#
|
||||
# 2. 字面路径 —— 含 `\`、`/` 或 `%` 就按路径处理,归档名沿用 <名>_from_<路径>
|
||||
# %UserProfile%\Documents\PowerShell
|
||||
# C:\Programs\MiFlash
|
||||
#
|
||||
# 3. 软件名 + @pathname —— 强制用旧的路径命名算法
|
||||
# FooClolor @pathname
|
||||
#
|
||||
# 排除模式:相对归档根目录。以 ! 开头表示"任意层级下匹配这个组件名"(7z 的 -xr!)。
|
||||
# 不要自己写引号;模式里的空格会被自动转成 ?(7z 的模式不支持空格)。
|
||||
# 标记:
|
||||
# encrypt 用 7z 加密该归档(口令来自 BAKNRET_PASSWORD 或 -KeyFile)
|
||||
# pathname 用路径命名算法而不是软件名
|
||||
# root=<名> 覆盖归档内的根目录名(默认就是软件名)
|
||||
#
|
||||
# 归档名 = 软件名,所以:**同一个软件不要写两遍**,脚本会直接报重复错误。
|
||||
# 软件名(连同排除规则、加密标记)都维护在 SoftwareCatalog.psd1 和本文件里,
|
||||
# 两边都进 git,改动可追溯。
|
||||
|
||||
# ---- 用户配置 / 开发环境 ----
|
||||
%UserProfile%/.config/legendary
|
||||
%UserProfile%/.config/scoop
|
||||
%UserProfile%/.ssh
|
||||
# 想加密 .ssh(内含私钥)就改成下面这行,并先配置好口令,见 README「加密」一节:
|
||||
# %UserProfile%/.ssh @encrypt
|
||||
%UserProfile%\Documents\CodeSpace :: Shuery-Shuai\ImmortalWrt-BPI-R4-Firmware\immortalwrt\
|
||||
%UserProfile%\Documents\PowerShell
|
||||
%UserProfile%\Documents\WindowsPowerShell
|
||||
%UserProfile%/scoop/persist
|
||||
legendary
|
||||
opencode
|
||||
scoop-config
|
||||
# .ssh 里是私钥。想加密就把下面那行 @encrypt 的注释互换(见 README「加密」)
|
||||
.ssh
|
||||
CodeSpace :: Shuery-Shuai\ImmortalWrt-BPI-R4-Firmware\immortalwrt\
|
||||
PowerShell
|
||||
WindowsPowerShell
|
||||
scoop-persist
|
||||
|
||||
# ---- 应用数据 ----
|
||||
%AppData%\AutoDarkMode
|
||||
%AppData%/com.example/Kazumi
|
||||
%AppData%/com.example/piliplus
|
||||
%AppData%\fnm
|
||||
%AppData%/twinkle-tray
|
||||
AutoDarkMode
|
||||
Kazumi
|
||||
piliplus
|
||||
fnm
|
||||
twinkle-tray
|
||||
|
||||
# ---- 浏览器:排除可再生的缓存、遥测与扩展本体 ----
|
||||
# 解压后 4.22 GB / 25030 个文件里,下面这组排除会留下约 431 MB / 2164 个文件,
|
||||
@@ -39,28 +51,26 @@
|
||||
# 扩展本体(可从商店重装)、遥测与优化数据。
|
||||
# 书签/密码/偏好/历史,以及站点数据(IndexedDB / Local Storage)都保留。
|
||||
# 想再省 230 MB,可以把 Default\IndexedDB、Default\Local Storage、
|
||||
# Default\Session Storage、Default\blob_storage、Default\WebStorage 也加进排除表。
|
||||
# !*Cache 表示"任意层级下以 Cache 结尾的目录",能一次覆盖 Cache / Code Cache /
|
||||
# GPUCache / DawnCache / GrShaderCache / ShaderCache 等一批。模式里的空格会被
|
||||
# 自动转成 ?(7z 的排除模式不支持空格)。
|
||||
%LocalAppData%\Microsoft\Edge\User Data :: !*Cache,component_crx_cache,Default\Service Worker,Default\Extensions,Default\ExtensionActivityEdge,ProvenanceData,optimization_guide,Crashpad,BrowserMetrics,Snapshots,Edge Sidebar,Edge Shopping
|
||||
%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json
|
||||
# Default\Session Storage、Default\blob_storage、Default\WebStorage 也加进来。
|
||||
# !*Cache 一次覆盖 Cache / Code Cache / GPUCache / DawnCache / GrShaderCache 等一批。
|
||||
#
|
||||
# 注意:Edge 常驻时打包会有上百个文件读不到(含 Login Data / Cookies),
|
||||
# 脚本检测到警告后不会用这份不完整的归档覆盖已有的完整归档。备份前建议先退出 Edge。
|
||||
Edge :: !*Cache,component_crx_cache,Default\Service Worker,Default\Extensions,Default\ExtensionActivityEdge,ProvenanceData,optimization_guide,Crashpad,BrowserMetrics,Snapshots,Edge Sidebar,Edge Shopping
|
||||
WindowsTerminal
|
||||
|
||||
# ---- 系统 ----
|
||||
%ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup
|
||||
Startup
|
||||
|
||||
# ---- C:\Programs ----
|
||||
C:\Programs\BaiduNetdisk
|
||||
C:\Programs\FooClolor
|
||||
C:\Programs\March7thAssistant :: 3rdparty\WebBrowser\UserProfile\Integrated,March7thAssistant\logs\
|
||||
C:\Programs\MiFlash
|
||||
C:\Programs\MiFlash_Unlock
|
||||
C:\Programs\QuarkCloudDrive
|
||||
C:\Programs\ScoopApps\apps\translucenttb\current\settings.json
|
||||
C:\Programs\ScoopApps\persist :: persist\ariang-native\UserData\DawnCache,persist\ariang-native\UserData\GPUCache,persist\ariang-native\UserData\Local Storage,persist\ariang-native\UserData\Session Storage
|
||||
BaiduNetdisk
|
||||
FooClolor
|
||||
March7thAssistant :: 3rdparty\WebBrowser\UserProfile\Integrated,March7thAssistant\logs\
|
||||
MiFlash
|
||||
MiFlash_Unlock
|
||||
QuarkCloudDrive
|
||||
translucenttb
|
||||
ScoopApps-persist :: persist\ariang-native\UserData\DawnCache,persist\ariang-native\UserData\GPUCache,persist\ariang-native\UserData\Local Storage,persist\ariang-native\UserData\Session Storage
|
||||
|
||||
# ---- 其它盘 ----
|
||||
D:\UserData\Documents\Aria
|
||||
D:\UserData\Documents\CodeSpace
|
||||
|
||||
E:\CodeSpace
|
||||
Aria
|
||||
+435
-4
@@ -417,17 +417,16 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
$excludes = @()
|
||||
if ($tailPart) {
|
||||
@@ -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'
|
||||
)
|
||||
@@ -1,7 +1,9 @@
|
||||
# BakNRet
|
||||
|
||||
把 `BackupList.txt` 里列出的目录 / 文件用 **7-Zip** 打包进 `Backups/`,并且能用 `Restore.ps1` 原样恢复的 Windows 备份工具。
|
||||
把 `BackupList.txt` 里列出的软件 / 目录用 **7-Zip** 打包进 `Backups/`,并且能用 `Restore.ps1` 原样恢复的 Windows 备份工具。
|
||||
|
||||
- 清单里**直接写软件名**即可(如 `FooClolor`),目录映射维护在 `SoftwareCatalog.psd1` 里。
|
||||
- 归档名就是软件名(`FooClolor.7z`),不再是 `FooClolor_from_C_+Programs.7z`。
|
||||
- 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,无需安装模块。
|
||||
- 每个归档写完后做 `7z t` 内容校验,**先写临时文件、校验通过再原子替换**。
|
||||
- 每次运行产出可核对的 `Backups/manifest.json` 与 `logs/*.log`。
|
||||
@@ -24,9 +26,9 @@
|
||||
# 3b. 确认可以接受"有文件被占用而没打进归档"时,允许覆盖完整归档
|
||||
.\Backup.ps1 -Force -AcceptWarnings
|
||||
|
||||
# 4. 只备份 / 只恢复某几项(通配符匹配路径或归档名)
|
||||
.\Backup.ps1 -Only '*.ssh','C:\Programs\FooClolor'
|
||||
.\Restore.ps1 -Only '*Edge*' -Force
|
||||
# 4. 只备份 / 只恢复某几项(通配符匹配清单条目或归档名)
|
||||
.\Backup.ps1 -Only 'FooClolor','.ssh'
|
||||
.\Restore.ps1 -Only 'Edge' -Force
|
||||
|
||||
# 5. 恢复前先看计划(恢复会覆盖真实目录,务必先看一眼)
|
||||
.\Restore.ps1 -DryRun
|
||||
@@ -39,60 +41,125 @@
|
||||
|
||||
| 路径 | 作用 |
|
||||
| --- | --- |
|
||||
| `SoftwareCatalog.psd1` | **软件名 → 目录**的映射,清单里写软件名的依据 |
|
||||
| `BackupList.txt` | 备份 / 恢复共用的清单,唯一的"要备份什么"来源 |
|
||||
| `BackupConfig.psd1` | 目录、空间阈值、校验、加密等配置 |
|
||||
| `Backup.ps1` | 备份入口 |
|
||||
| `Restore.ps1` | 恢复入口 |
|
||||
| `Common.psm1` | 公共模块(日志、外部命令、解析、manifest) |
|
||||
| `Backup.ps1` / `Restore.ps1` | 备份 / 恢复入口 |
|
||||
| `Common.psm1` | 公共模块(日志、外部命令、解析、名录、manifest) |
|
||||
| `Backups/` | 归档与 `manifest.json`(已 gitignore) |
|
||||
| `logs/` | 每次运行的日志(已 gitignore) |
|
||||
| `tests/` | 单元测试与端到端验收 |
|
||||
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
|
||||
| `tools/Rename-Archives.ps1` | 把按路径命名的旧归档重命名成软件名(默认试运行) |
|
||||
|
||||
## SoftwareCatalog.psd1 —— 软件名 → 目录
|
||||
|
||||
```powershell
|
||||
@{
|
||||
FooClolor = 'C:\Programs\FooClolor'
|
||||
Kazumi = '%AppData%\com.example\Kazumi'
|
||||
'scoop-config' = '%UserProfile%\.config\scoop' # 含 - 或 . 的键必须加引号
|
||||
'.ssh' = '%UserProfile%\.ssh'
|
||||
}
|
||||
```
|
||||
|
||||
**含 `-` 或 `.` 的键一定要加引号**,否则 PowerShell 会把 `a-b` 解析成减法表达式并报
|
||||
`Missing '=' operator after key in hash literal`。这是最容易踩的一个坑。
|
||||
|
||||
两个便利特性:
|
||||
|
||||
1. **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配。
|
||||
只认 `<名>_*` 与 `<名>-*`,不会把 `Legendary` 误配成 `LegendarySomething`。
|
||||
2. **同名目录在多处**时显式列出,所有位置都会打进同一个归档:
|
||||
|
||||
```powershell
|
||||
ImHex = @{
|
||||
Path = 'D:\Hex\ImHex'
|
||||
Variants = @('D:\Hex\ImHex', 'E:\Backup\ImHex')
|
||||
}
|
||||
```
|
||||
|
||||
**分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件):
|
||||
|
||||
```powershell
|
||||
@{
|
||||
Includes = @('SoftwareCatalog.games.psd1')
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## BackupList.txt 语法
|
||||
|
||||
```text
|
||||
<路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
|
||||
<软件名 或 路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
|
||||
```
|
||||
|
||||
| 部分 | 说明 |
|
||||
三种写法可以混用:
|
||||
|
||||
| 写法 | 说明 |
|
||||
| --- | --- |
|
||||
| 路径 | 支持 `%环境变量%`;可用双引号包裹(**引号只包路径**);`/` 与 `\` 等价;`#` 开头是注释 |
|
||||
| 排除模式 | 相对归档根目录(源目录的末级名)。分隔符 `,` 与 `;` 都可以 |
|
||||
| `!` 前缀 | 表示"任意层级下匹配这个名字",翻译成 7z 的 `-xr!`,例:`!*Cache` |
|
||||
| 标记 | `encrypt` = 用 7z 加密该归档,见下文「加密」 |
|
||||
| `FooClolor` | 软件名。去名录查目录,**归档名 = 软件名** |
|
||||
| `%UserProfile%\Documents\PowerShell` | 字面路径(含 `\` `/` 或 `%` 就按路径处理),归档名沿用 `<末级名>_from_<上级路径>` |
|
||||
| `FooClolor @pathname` | 软件名 + 强制用路径命名。适合想换到名录体系但暂时不想改归档名的条目 |
|
||||
|
||||
| 标记 | 作用 |
|
||||
| --- | --- |
|
||||
| `encrypt` | 用 7z 加密该归档,见下文「加密」 |
|
||||
| `pathname` | 用路径命名算法而不是软件名 |
|
||||
| `root=<名>` | 覆盖归档内的根目录名(默认源目录名) |
|
||||
|
||||
排除模式:相对归档根目录。以 `!` 开头表示"任意层级下匹配这个组件名"(7z 的 `-xr!`)。
|
||||
分隔符 `,` 与 `;` 都可以。**不要自己写引号**;模式里的空格会被自动转成 `?`。
|
||||
|
||||
几条已经踩过的坑(工具会处理,写的时候知道就行):
|
||||
|
||||
- **模式里不要写引号。** `-x!"路径"` 会让引号成为模式的一部分,结果是**永不匹配**。
|
||||
- **模式里的空格会被自动转成 `?`。** 7z 的排除模式不支持空格:`Default\Code Cache` 匹配不到任何东西,`Default\Code?Cache` 才可以。
|
||||
- **以第一个 `::` 为界切分。** `:` 在 Windows 路径里只可能是盘符,`::` 不会出现在真实路径里,所以整行被一对引号包住的历史写法(`"路径 :: 排除表"`)也能正确解析。
|
||||
- **改名即换归档。** 归档名由路径生成:`<末级名>_from_<上级路径用 + 连接>`。改动路径会生成新归档,旧归档需靠 `manifest.json` 找回。
|
||||
- **以第一个 `::` 为界切分。** `:` 在 Windows 路径里只可能是盘符,`::` 不会出现在真实路径里,所以整行被一对引号包住的历史写法也能正确解析。
|
||||
- **归档名重复会直接报错。** 归档名就是软件名,所以同一个软件写两遍会让两个条目互相覆盖 —— 脚本拒绝执行并提示。
|
||||
|
||||
## 归档命名与迁移
|
||||
|
||||
| 条目类型 | 归档名 |
|
||||
| --- | --- |
|
||||
| 软件名 | `<软件名>.7z` |
|
||||
| 字面路径 | `<末级名>_from_<上级路径用 + 连接>.7z` |
|
||||
| 软件名 + `@pathname` | 同字面路径 |
|
||||
|
||||
从旧版本升级时用重命名工具把存量归档搬过来(**默认试运行**、逐份大小校验、重建 manifest):
|
||||
|
||||
```powershell
|
||||
.\tools\Rename-Archives.ps1 # 先看计划
|
||||
.\tools\Rename-Archives.ps1 -Apply # 确认后执行
|
||||
```
|
||||
|
||||
## 恢复语义
|
||||
|
||||
- 用 `7z x` 解压到目标的**父目录**,覆盖同名文件。
|
||||
- **归档内部布局与历史完全一致**:根目录仍是源目录名(软件名只用于归档文件名)。
|
||||
所以恢复逻辑不需要"剥掉一层",现有归档也不会因为重命名而解不开。
|
||||
- **不做镜像同步**:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。
|
||||
- 目标目录比归档新时**默认跳过**,需要覆盖就加 `-Force`。
|
||||
- `-WhatIf` / `-DryRun` 只打印计划;`-VerifyOnly` 只跑 `7z t`。
|
||||
- **排除规则只在下一份归档里生效**:已经生成的归档不会因为改了排除表而"变干净",需要重跑备份才会生成新归档。
|
||||
- **排除规则只在下一份归档里生效**:已经生成的归档不会因为改了排除表而"变干净"。
|
||||
|
||||
## manifest.json
|
||||
|
||||
`Backups/manifest.json` 以归档基础名为键记录每个条目:
|
||||
以归档基础名为键记录每个条目:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `source` / `resolvedSource` | 清单里的原始路径(未展开环境变量)/ 实际路径 |
|
||||
| `source` | 清单里的原始写法(软件名或路径) |
|
||||
| `resolvedSource` | 展开后的路径 |
|
||||
| `roots` | 归档内的根目录名 |
|
||||
| `catalog` | 名录里记录的路径(便于追溯软件名到底指向哪) |
|
||||
| `archive` | 归档文件名 |
|
||||
| `action` | `backed-up` / `skip-unchanged` / `missing-source` / `invalid-path` / `failed` / `planned` |
|
||||
| `reason` | 跳过或失败的原因 |
|
||||
| `exitCode` / `verified` / `warnings` | 压缩工具退出码、是否通过 `7z t`、**当前在位归档**是否有警告 |
|
||||
| `attemptWarnings` | **本次尝试**是否报了警告(与 `warnings` 区分:保留旧归档时前者为 true、后者仍为 false) |
|
||||
| `sourceFiles` / `sourceBytes` / `archiveBytes` | 源文件数、源大小、归档大小 |
|
||||
| `startedAt` / `finishedAt` / `durationSec` | 时间与耗时 |
|
||||
| `lastSuccessAt` / `successCount` / `failCount` / `lastRestoreAt` | 历史 |
|
||||
| `encrypted` | 是否为加密归档(恢复时据此判断是否需要口令) |
|
||||
| `lastSuccessAt` / `successCount` / `failCount` / `lastRestoreAt` / `encrypted` | 历史与安全标记 |
|
||||
|
||||
`Restore.ps1` **优先用 manifest 定位归档**,查不到才退回"从文件名反推路径"。
|
||||
如果 `BackupList.txt` 丢了,`Restore.ps1` 会优先用 manifest 里的 `source` 自动重建。
|
||||
@@ -109,7 +176,9 @@
|
||||
BackupDir = 'Backups' # 相对路径按脚本所在目录解析
|
||||
LogDir = 'logs'
|
||||
SnapshotDir = 'Backups\snapshots'
|
||||
MinFreeSpaceGB = 5 # 低于此值告警;真放不下某个条目则跳过该条目
|
||||
SoftwareCatalog = 'SoftwareCatalog.psd1'
|
||||
CatalogMaxDepth = 5 # 前缀补全时最多向下找几层
|
||||
MinFreeSpaceGB = 5
|
||||
VerifyArchive = $true # 归档后跑 7z t
|
||||
ComputeHash = $false # 是否额外算 SHA256(大归档很慢)
|
||||
CompressionLevel = 9
|
||||
@@ -120,16 +189,16 @@
|
||||
}
|
||||
```
|
||||
|
||||
优先级:**命令行参数 > `BackupConfig.psd1` > 代码内置默认值**。也可以用 `-ConfigPath` 指定其它配置文件。
|
||||
优先级:**命令行参数 > `BackupConfig.psd1` > 代码内置默认值**,也可以用 `-ConfigPath` 指定其它配置文件。
|
||||
|
||||
## 加密
|
||||
|
||||
加密是**按需开启**的,默认关闭——一旦开启而口令丢失,备份就再也解不开。
|
||||
默认关闭 —— 一旦开启而口令丢失,备份就再也解不开。
|
||||
|
||||
```powershell
|
||||
# 方式一:只为个别条目加密(.ssh 里是私钥,最典型)
|
||||
# 在 BackupList.txt 里写成:
|
||||
# %UserProfile%/.ssh @encrypt
|
||||
# .ssh @encrypt
|
||||
|
||||
# 方式二:全部加密,改配置
|
||||
# Encryption = @{ Enabled = $true; PasswordFile = 'D:\secret\baknret.key' }
|
||||
@@ -142,31 +211,23 @@ $env:BAKNRET_PASSWORD = '...' # 或
|
||||
要求加密但取不到口令时,该条目会**明确失败**,绝不会退化成明文归档。
|
||||
恢复加密归档时同理:取不到口令就直接失败,不会让 7z 停在控制台等待输入(在计划任务里那会静默挂起)。
|
||||
|
||||
> ⚠️ 7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 7z 本身的限制,请自行权衡。加密保护的是"归档落在盘上之后"。
|
||||
> ⚠️ 7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 7z 本身的限制,请自行权衡。
|
||||
|
||||
## 计划任务
|
||||
|
||||
```powershell
|
||||
# 注册:每天 21:30 备份(默认用最高权限运行,因为部分目录需要管理员)
|
||||
.\tools\Register-BackupTask.ps1 -At '21:30'
|
||||
|
||||
# 只看将要注册什么
|
||||
.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun
|
||||
|
||||
# 移除
|
||||
.\tools\Register-BackupTask.ps1 -Remove
|
||||
.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun # 先看将要注册什么
|
||||
.\tools\Register-BackupTask.ps1 -At '21:30' # 注册
|
||||
.\tools\Register-BackupTask.ps1 -Remove # 移除
|
||||
```
|
||||
|
||||
任务会调用 `Backup.ps1`,脚本自身写日志并按失败数返回退出码,所以「上次运行结果」在任务计划程序里是可读的。
|
||||
任务调用 `Backup.ps1`,脚本自身写日志并按失败数返回退出码,所以「上次运行结果」在任务计划程序里可读。
|
||||
|
||||
## 测试
|
||||
|
||||
```powershell
|
||||
# 单元测试:解析、命名、排除参数翻译、命令行拼接、manifest、配置 + 真实 7z 集成
|
||||
.\tests\Run-Tests.ps1
|
||||
|
||||
# 端到端验收:备份 -> 验证排除 -> 删源 -> 恢复 -> 逐字节对拍(全程在临时目录)
|
||||
.\tests\Run-E2E.ps1
|
||||
.\tests\Run-Tests.ps1 # 单元测试 42 项
|
||||
.\tests\Run-E2E.ps1 # 端到端验收 23 项
|
||||
```
|
||||
|
||||
零依赖,不需要 Pester(本机只有 3.4.0,`Should -Be` 会直接语法错误)。
|
||||
@@ -175,29 +236,32 @@ $env:BAKNRET_PASSWORD = '...' # 或
|
||||
|
||||
| 问题 | 旧行为 | 现行为 |
|
||||
| --- | --- | --- |
|
||||
| `Start-Process -PassThru` 的 `ExitCode` 在 PowerShell 7.7.0-preview.4 上恒为 `$null` | 压缩明明成功(`Everything is Ok`)却报"压缩失败",`exit 2 → 删档重试` 的自愈分支永远不可达 | 用 `.NET Process` 继承控制台启动,退出码可靠 |
|
||||
| `Start-Process -PassThru` 的 `ExitCode` 在 PowerShell 7.7.0-preview.4 上恒为 `$null` | 压缩明明成功却报"压缩失败",`exit 2 → 删档重试` 的自愈分支永远不可达 | 用 `.NET Process` 继承控制台启动,退出码可靠 |
|
||||
| 排除模式写成 `-x!"路径"` | 引号成为模式的一部分,**排除对所有条目都失效** | 不再嵌引号;含空格自动转 `?`,`!` 前缀走 `-xr!` |
|
||||
| 解析器用 `;` 分隔,清单里写的是 `,` | 整串被当成一个模式,等于没有排除 | `,` 与 `;` 都支持 |
|
||||
| `^"([^"]+)"` 贪婪匹配 | 整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,且 2.8 GB 归档成了找不到的孤儿 | 先按 `::` 切分再处理引号 |
|
||||
| `^"([^"]+)"` 贪婪匹配 | 整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,2.8 GB 归档成了孤儿 | 先按 `::` 切分再处理引号 |
|
||||
| 归档名由路径拼出 | 加一条备份要自己算名字,名字随路径变动 | 清单写软件名,归档名就是软件名 |
|
||||
| 直接更新已有归档(7z `u`) | 固实归档下收益极小,且排除规则与"源里已删的文件"永远反映不到归档里 | 临时文件 → `7z t` 校验 → 原子替换 |
|
||||
| 没有校验、没有记录 | 中断留下的半个归档会被下次 `u` 续写;跳过/失败只有一行滚过去的 WARN | 校验 + 原子替换 + `manifest.json` + 日志文件 |
|
||||
| 结尾不 `exit` | 全部失败也返回 0,计划任务永远显示成功 | 有失败返回 1 |
|
||||
| 恢复用 `-Filter "$baseName.*"` | 含 `[` `]` 的路径会失配 | 精确比较 `BaseName`,且优先查 manifest |
|
||||
| tar 分支 `$LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0` | `$LASTEXITCODE` 是上一条原生命令的残留值,恰为 0 时把解压失败吞掉 | 三条分支统一走同一个取退出码的封装 |
|
||||
| 恢复没有干跑 | 直接覆盖 `E:\CodeSpace`、Edge User Data 这类真实目录 | `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` |
|
||||
| 没有 manifest、没有测试、没有 README、不是 git 仓库 | — | 都有 |
|
||||
| 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 |
|
||||
|
||||
## 设计取舍(有意为之,不是遗漏)
|
||||
|
||||
- **放弃 7z 的更新模式(`u`)。** 7z 默认固实压缩,`u` 本来就要重压大部分数据,收益很小,却让"排除规则改动"和"源里删掉的文件"永远进不了归档。现在每次都从零打包,代价是改动的条目会全量重压,换来的是归档与清单语义一致。
|
||||
- **不捕获压缩工具的输出。** 结构化记录交给日志与 `manifest.json`;捕获子进程 stdio 需要额外管道,在受限环境里会直接失败,而实时进度对交互式使用更有用。需要安静就跑 `-QuietTool`。
|
||||
- **有警告(退出码 1)时不覆盖完整的归档。** 被占用的文件(最典型的是正在运行的 Edge / 浏览器)会让 7z 返回 1,此时新归档是**不完整**的。实测:Edge 运行时打包,118 个文件读不到,其中包含 `Login Data`(密码)、`Cookies`、`History`、`Web Data` —— 恰恰是最不可再生的那部分。所以只要在位的归档是完整的(manifest `warnings=false`),脚本就**保留它、报失败、退出码 1**,不会用残缺归档把它换掉。确认可以接受再显式加 `-AcceptWarnings`。
|
||||
- **源路径不存在只算"跳过",不算失败。** 清单里留着已不存在的路径(例如换过盘的 `E:\CodeSpace`)是正常的,它会以 `missing-source` 记进 manifest。失败只统计真正打不开的条目。
|
||||
- **`DefaultExcludes` 只影响打包,不影响恢复。**
|
||||
- **放弃 7z 的更新模式(`u`)。** 7z 默认固实压缩,`u` 本来就要重压大部分数据,收益很小,却让"排除规则改动"和"源里删掉的文件"永远进不了归档。
|
||||
- **归档内部不套一层软件名目录。** 考虑过用暂存目录(硬链/复制)把归档根目录改成软件名,代价是多一次链接开销、实现复杂度上升,收益只是"解开包第一层好看"。归档名已经是软件名,包内保持源目录名也便于确认内容来源。顺带一提,7z 的 `-spf` 不是干这个的(它是 *use fully qualified file paths*)。
|
||||
- **不捕获压缩工具的输出。** 结构化记录交给日志与 `manifest.json`;捕获子进程 stdio 需要额外管道,在受限环境里会直接失败。
|
||||
- **有警告(退出码 1)时不覆盖完整的归档。** 被占用的文件会让 7z 返回 1,此时新归档是**不完整**的。实测 Edge 运行时打包,118 个文件读不到,其中包含 `Login Data`(密码)、`Cookies`、`History`、`Web Data`。所以在位归档完整时脚本**保留它、报失败、退出码 1**,确认可以接受再显式加 `-AcceptWarnings`。
|
||||
- **名录里的路径不存在时,恢复仍然可用。** 源被删掉正是要恢复的场景,所以解析器照旧给出 `Sources`,备份端则据此跳过。
|
||||
- **源路径不存在只算"跳过",不算失败。** 会以 `missing-source` 记进 manifest。失败只统计真正打不开的条目。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 归档名与路径强耦合:改清单里的路径写法会生成新归档名。恢复时 manifest 能兜底,但**别轻易改已备份条目的路径写法**。
|
||||
- **改软件名等于换归档名。** 改名后旧归档不会被自动迁移,用 `tools/Rename-Archives.ps1` 或手动改名,并注意 manifest 里会留下旧键。
|
||||
- 路径里本来就含 `+` 或 `_from_` 时,仅靠文件名无法可靠反推路径,此时依赖 `manifest.json`。
|
||||
- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 已保留在配置里,清理逻辑尚未实现)。
|
||||
- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 尚未实现)。
|
||||
- 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。
|
||||
- `Variants`(同名目录分散在多处)当前打包第一个位置,恢复时逐个位置各解压一份。
|
||||
+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
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<#
|
||||
软件名录:维护"软件名 -> 目录"的映射。
|
||||
|
||||
有这个文件之后,BackupList.txt 里可以直接写软件名:
|
||||
|
||||
FooClolor
|
||||
Kazumi :: !*Cache
|
||||
Edge :: !*Cache,component_crx_cache
|
||||
.ssh @encrypt
|
||||
|
||||
归档包的名字也就是软件名(`FooClolor.7z`),不再是
|
||||
`FooClolor_from_C_+Programs.7z` 这种由路径拼出来的名字。
|
||||
|
||||
写法:
|
||||
|
||||
<软件名> = '<目录>'
|
||||
|
||||
软件名的限制:
|
||||
* 必须是合法的文件名(不能含 \ / : * ? " < > |),因为它就是归档名;
|
||||
* 不能含 `\` 或 `/` 或 `%`,否则会被当作字面路径而不是软件名;
|
||||
* **含 `-` 或 `.` 的名字必须写成带引号的键**,否则 PowerShell 会把
|
||||
`a-b` 解析成减法表达式并报 "Missing '=' operator":
|
||||
'scoop-config' = '...' # 正确
|
||||
scoop-config = '...' # 报错
|
||||
* 建议用英文/数字,但中文也可以。
|
||||
|
||||
目录可以写环境变量,例如 '%UserProfile%\.ssh'。
|
||||
|
||||
两个便利特性:
|
||||
|
||||
1. 目录不存在时会按前缀补全:写 'D:\Programs\legendary',实际目录是
|
||||
'D:\Programs\legendary_2.0.4',会自动匹配(只认 `<名>_*` 与 `<名>-*`,
|
||||
不会把 Legendary 误配成 LegendarySomething)。
|
||||
2. 同名目录出现在多个位置时,用字典形式显式列出,所有位置都会打进同一个归档:
|
||||
|
||||
ImHex = @{
|
||||
Path = 'D:\Hex\ImHex'
|
||||
Variants = @('D:\Hex\ImHex', 'E:\Backup\ImHex')
|
||||
}
|
||||
|
||||
分文件维护:用 Includes 引入其它名录文件(路径相对本文件):
|
||||
|
||||
@{
|
||||
Includes = @('SoftwareCatalog.games.psd1')
|
||||
...
|
||||
}
|
||||
#>
|
||||
@{
|
||||
# ---- 用户配置 / 开发环境 ----
|
||||
# 含 `-` 或 `.` 的键必须加引号,否则会被当成减法表达式(见文件开头说明)
|
||||
legendary = '%UserProfile%\.config\legendary'
|
||||
opencode = '%UserProfile%\.config\opencode'
|
||||
'scoop-config' = '%UserProfile%\.config\scoop'
|
||||
'scoop-persist' = '%UserProfile%\scoop\persist'
|
||||
'.ssh' = '%UserProfile%\.ssh'
|
||||
CodeSpace = 'D:\UserData\Documents\CodeSpace'
|
||||
PowerShell = '%UserProfile%\Documents\PowerShell'
|
||||
WindowsPowerShell = '%UserProfile%\Documents\WindowsPowerShell'
|
||||
|
||||
# ---- 应用数据 ----
|
||||
AutoDarkMode = '%AppData%\AutoDarkMode'
|
||||
Kazumi = '%AppData%\com.example\Kazumi'
|
||||
piliplus = '%AppData%\com.example\piliplus'
|
||||
fnm = '%AppData%\fnm'
|
||||
'twinkle-tray' = '%AppData%\twinkle-tray'
|
||||
|
||||
# ---- 浏览器与终端 ----
|
||||
Edge = '%LocalAppData%\Microsoft\Edge\User Data'
|
||||
WindowsTerminal = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json'
|
||||
|
||||
# ---- 系统 ----
|
||||
Startup = '%ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup'
|
||||
|
||||
# ---- C:\Programs ----
|
||||
BaiduNetdisk = 'C:\Programs\BaiduNetdisk'
|
||||
FooClolor = 'C:\Programs\FooClolor'
|
||||
March7thAssistant = 'C:\Programs\March7thAssistant'
|
||||
MiFlash = 'C:\Programs\MiFlash'
|
||||
MiFlash_Unlock = 'C:\Programs\MiFlash_Unlock'
|
||||
QuarkCloudDrive = 'C:\Programs\QuarkCloudDrive'
|
||||
translucenttb = 'C:\Programs\ScoopApps\apps\translucenttb\current\settings.json'
|
||||
'ScoopApps-persist' = 'C:\Programs\ScoopApps\persist'
|
||||
|
||||
# ---- 其它盘 ----
|
||||
Aria = 'D:\UserData\Documents\Aria'
|
||||
}
|
||||
+88
-1
@@ -253,7 +253,94 @@ Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' {
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 6. 失败路径:源不存在时必须留下可核对的记录
|
||||
# 6. 软件名录:清单里写软件名,归档名就是软件名
|
||||
# ============================================================================
|
||||
|
||||
$catRoot = Join-Path $WorkRoot 'catalog'
|
||||
$catSource = Join-Path $catRoot 'src'
|
||||
$catTarget = Join-Path $catSource 'My App' # 真实目录名与软件名刻意不同
|
||||
$catBackupDir = Join-Path $catRoot 'Backups'
|
||||
$catFile = Join-Path $catRoot 'SoftwareCatalog.psd1'
|
||||
$catList = Join-Path $catRoot 'list.txt'
|
||||
$catConfig = Join-Path $catRoot 'config.psd1'
|
||||
|
||||
New-Item -ItemType Directory -Path $catTarget -Force | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $catTarget 'data.txt') -Value 'catalog-test' -Encoding UTF8
|
||||
Set-Content -LiteralPath (Join-Path $catTarget 'skip.bin') -Value 'nope' -Encoding UTF8
|
||||
|
||||
[System.IO.File]::WriteAllText($catFile, "@{`n 'my-app' = '$catTarget'`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||||
[System.IO.File]::WriteAllText($catList, "my-app :: skip.bin`n", [System.Text.UTF8Encoding]::new($false))
|
||||
[System.IO.File]::WriteAllText($catConfig, "@{ SoftwareCatalog = '$catFile' }`n", [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
& $backupScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
|
||||
$catExitCode = $LASTEXITCODE
|
||||
$catArchive = Join-Path $catBackupDir 'my-app.7z'
|
||||
|
||||
Test-Case '清单里写软件名 -> 归档名就是软件名' {
|
||||
Assert-Equal 0 $catExitCode
|
||||
Assert-FileExists $catArchive
|
||||
}
|
||||
|
||||
Test-Case '软件名条目的归档内容与历史布局一致(根目录仍是源目录名)' {
|
||||
$extract = Join-Path $catRoot 'verify'
|
||||
New-Item -ItemType Directory -Path $extract -Force | Out-Null
|
||||
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||
if ($sevenZip) {
|
||||
$null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$extract", $catArchive)
|
||||
# 归档文件名是软件名 my-app,但包内根目录是源目录名 My App
|
||||
Assert-FileExists (Join-Path $extract 'My App\data.txt')
|
||||
Assert-FileMissing (Join-Path $extract 'my-app') '包内不应多出一层软件名'
|
||||
Assert-FileMissing (Join-Path $extract 'My App\skip.bin') '排除模式以源目录名为前缀,仍然生效'
|
||||
}
|
||||
}
|
||||
|
||||
Test-Case '@pathname 覆盖:强制用路径命名算法(用独立备份目录,避免污染共享 manifest)' {
|
||||
$pathList = Join-Path $catRoot 'list-pathname.txt'
|
||||
$pathNameDir = Join-Path $catRoot 'Backups-pathname'
|
||||
[System.IO.File]::WriteAllText($pathList, "my-app @pathname`n", [System.Text.UTF8Encoding]::new($false))
|
||||
& $backupScript -BackupListPath $pathList -BackupDir $pathNameDir -ConfigPath $catConfig -Force -QuietTool
|
||||
Assert-Equal 0 $LASTEXITCODE
|
||||
|
||||
# 名录里存的是绝对路径,所以路径命名结果也基于它
|
||||
$expectedBase = Get-BackupBaseName -RawPath $catTarget
|
||||
Assert-FileExists (Join-Path $pathNameDir ($expectedBase + '.7z'))
|
||||
}
|
||||
|
||||
Test-Case '软件名录条目:删源后能按原路径恢复' {
|
||||
Remove-Item -LiteralPath $catTarget -Recurse -Force
|
||||
& $restoreScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force
|
||||
Assert-Equal 0 $LASTEXITCODE
|
||||
Assert-FileExists (Join-Path $catTarget 'data.txt')
|
||||
Assert-Equal 'catalog-test' (Get-Content -LiteralPath (Join-Path $catTarget 'data.txt') -Raw).Trim()
|
||||
}
|
||||
|
||||
|
||||
Test-Case '归档名重复时直接报失败,不静默互相覆盖' {
|
||||
$dupList = Join-Path $catRoot 'dup.txt'
|
||||
[System.IO.File]::WriteAllText($dupList, "my-app`nmy-app`n", [System.Text.UTF8Encoding]::new($false))
|
||||
& $backupScript -BackupListPath $dupList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
|
||||
Assert-Equal 1 $LASTEXITCODE '重复条目应让退出码为 1'
|
||||
}
|
||||
|
||||
Test-Case '字面路径不受名录影响,仍走路径命名' {
|
||||
$literalList = Join-Path $catRoot 'list-literal.txt'
|
||||
[System.IO.File]::WriteAllText($literalList, "$catTarget`n", [System.Text.UTF8Encoding]::new($false))
|
||||
& $backupScript -BackupListPath $literalList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
|
||||
Assert-Equal 0 $LASTEXITCODE
|
||||
}
|
||||
|
||||
Test-Case '名录里没有该软件名时记为 missing-source,而不是崩掉' {
|
||||
$badList = Join-Path $catRoot 'bad.txt'
|
||||
[System.IO.File]::WriteAllText($badList, "no-such-app`n", [System.Text.UTF8Encoding]::new($false))
|
||||
& $backupScript -BackupListPath $badList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool
|
||||
Assert-Equal 0 $LASTEXITCODE '跳过不算失败'
|
||||
$rec = (Read-BaknretManifest -Path (Join-Path $catBackupDir 'manifest.json')).items['no-such-app']
|
||||
Assert-True ($null -ne $rec) '应留下记录'
|
||||
Assert-Equal 'missing-source' $rec.action
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 7. 失败路径:源不存在时必须留下可核对的记录
|
||||
# ============================================================================
|
||||
|
||||
$missingList = Join-Path $WorkRoot 'missing.txt'
|
||||
|
||||
@@ -251,6 +251,105 @@ Test-Case '口令:环境变量可读取,取不到返回 $null' {
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
Write-Host "`n== 软件名录 ==" -ForegroundColor Cyan
|
||||
# ============================================================================
|
||||
|
||||
$catalogSandbox = Join-Path $env:TEMP ("baknret-cat-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
New-Item -ItemType Directory -Path $catalogSandbox -Force | Out-Null
|
||||
|
||||
Test-Case '名录解析:裸键、引号键、带 - 与 . 的名字' {
|
||||
$catPath = Join-Path $catalogSandbox 'SoftwareCatalog.psd1'
|
||||
$content = "@{`n FooClolor = 'C:\Programs\FooClolor'`n 'scoop-config' = '%UserProfile%\.config\scoop'`n '.ssh' = '%UserProfile%\.ssh'`n}`n"
|
||||
[System.IO.File]::WriteAllText($catPath, $content, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$catalog = Get-SoftwareCatalog -Path $catPath
|
||||
Assert-Equal 3 $catalog.Count
|
||||
Assert-True $catalog.ContainsKey('FooClolor')
|
||||
Assert-True $catalog.ContainsKey('scoop-config') '带连字符的键必须能解析'
|
||||
Assert-True $catalog.ContainsKey('.ssh') '带点的键必须能解析'
|
||||
}
|
||||
|
||||
Test-Case '名录解析:目录带版本后缀时按前缀补全(legendary_2.0.4)' {
|
||||
$root = Join-Path $catalogSandbox 'probe'
|
||||
New-Item -ItemType Directory -Path (Join-Path $root 'legendary_2.0.4') -Force | Out-Null
|
||||
$catPath = Join-Path $catalogSandbox 'suffix.psd1'
|
||||
[System.IO.File]::WriteAllText($catPath, "@{`n legendary = '$root\legendary'`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$catalog = Get-SoftwareCatalog -Path $catPath
|
||||
Assert-Equal 'Single' $catalog['legendary'].Kind
|
||||
Assert-True ($catalog['legendary'].ResolvedPath -like '*legendary_2.0.4') '应补全到实际目录'
|
||||
}
|
||||
|
||||
Test-Case '名录解析:不会把 Legendary 误配成 LegendarySomething' {
|
||||
$root = Join-Path $catalogSandbox 'strict'
|
||||
New-Item -ItemType Directory -Path (Join-Path $root 'LegendarySomething') -Force | Out-Null
|
||||
$catPath = Join-Path $catalogSandbox 'strict.psd1'
|
||||
[System.IO.File]::WriteAllText($catPath, "@{`n Legendary = '$root\Legendary'`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$catalog = Get-SoftwareCatalog -Path $catPath
|
||||
Assert-Equal 'Unresolved' $catalog['Legendary'].Kind '必须以下一个字符是 _ 或 - 为界'
|
||||
}
|
||||
|
||||
Test-Case '软件名条目:默认用软件名做归档名' {
|
||||
$item = ConvertFrom-BackupListLine -Line 'FooClolor'
|
||||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1')
|
||||
Assert-Equal 'FooClolor' $resolved.BaseName
|
||||
Assert-Equal 'name' $resolved.ArchiveFlavor
|
||||
Assert-True $resolved.IsName
|
||||
}
|
||||
|
||||
Test-Case '字面路径条目:仍用路径命名算法(现有清单无需改写)' {
|
||||
$item = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
|
||||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1')
|
||||
Assert-False $resolved.IsName
|
||||
Assert-Equal 'path' $resolved.ArchiveFlavor
|
||||
Assert-Equal 'FooClolor_from_C_+Programs' $resolved.BaseName
|
||||
}
|
||||
|
||||
Test-Case '@pathname 用真实路径命名,而不是软件名' {
|
||||
$item = ConvertFrom-BackupListLine -Line 'FooClolor @pathname'
|
||||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1')
|
||||
Assert-Equal 'path' $resolved.ArchiveFlavor
|
||||
Assert-Equal 'FooClolor_from_C_+Programs' $resolved.BaseName
|
||||
}
|
||||
|
||||
Test-Case '名录里没有该名字:BaseName 退回可读目录名,并给出 Error' {
|
||||
$item = ConvertFrom-BackupListLine -Line 'no-such-thing'
|
||||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1')
|
||||
Assert-Equal 'no-such-thing' $resolved.BaseName
|
||||
Assert-True ($null -ne $resolved.Error) '应给出错误说明'
|
||||
Assert-Equal 0 $resolved.Sources.Count
|
||||
}
|
||||
|
||||
Test-Case '名录里的路径不存在时仍给出 Sources(恢复要靠它还原回原位)' {
|
||||
$catPath = Join-Path $catalogSandbox 'gone.psd1'
|
||||
[System.IO.File]::WriteAllText($catPath, "@{`n 'gone-app' = 'C:\definitely\not\here'`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||||
$item = ConvertFrom-BackupListLine -Line 'gone-app'
|
||||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath
|
||||
Assert-True ($null -ne $resolved.Error) '备份端据 Error 跳过'
|
||||
Assert-Equal 1 $resolved.Sources.Count '恢复端据 Sources 还原'
|
||||
Assert-Equal 'C:\definitely\not\here' $resolved.Sources[0].SourcePath
|
||||
}
|
||||
|
||||
Test-Case 'Includes:分文件维护的名录会被合并' {
|
||||
$basePath = Join-Path $catalogSandbox 'Base.psd1'
|
||||
$extraPath = Join-Path $catalogSandbox 'Extra.psd1'
|
||||
[System.IO.File]::WriteAllText($extraPath, "@{`n 'extra-app' = 'C:\Programs\Extra'`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||||
[System.IO.File]::WriteAllText($basePath, "@{`n Includes = @('Extra.psd1')`n 'base-app' = 'C:\Programs\Base'`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$catalog = Get-SoftwareCatalog -Path $basePath
|
||||
Assert-True $catalog.ContainsKey('base-app')
|
||||
Assert-True $catalog.ContainsKey('extra-app') 'Includes 引入的条目也应在'
|
||||
}
|
||||
|
||||
Test-Case '软件名会被规范化成合法文件名' {
|
||||
Assert-Equal 'keep%and&and+plus' (Format-CatalogName -Name 'keep%and&and+plus')
|
||||
Assert-True ((Format-CatalogName -Name 'a/b:c') -notmatch '[/:]') '非法字符应被替换'
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath $catalogSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# ============================================================================
|
||||
Write-Host "`n== 集成测试:真的跑 7z,验证排除结果落在文件系统上 ==" -ForegroundColor Cyan
|
||||
# ============================================================================
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
把按路径命名的旧归档重命名成软件名,并重建 manifest.json。
|
||||
|
||||
.DESCRIPTION
|
||||
重构前的归档名是 `<末级名>_from_<上级路径>`(如 FooClolor_from_C_+Programs.7z)。
|
||||
引入软件名录后,归档名默认就是软件名(FooClolor.7z)。这个脚本负责把存量归档搬过去。
|
||||
|
||||
做法:
|
||||
1. 遍历清单条目,算出"旧名"(路径命名算法)与"新名"(当前规则);
|
||||
2. 只在两者不同、且旧名归档确实存在时才处理;
|
||||
3. 重命名(不是复制,同卷上是元数据操作,不搬数据);
|
||||
4. 重建 manifest.json,把旧记录的历史字段(成功次数、SHA256 等)迁过去;
|
||||
5. 比对重命名前后的文件大小做完整性自检。
|
||||
|
||||
默认是**试运行**,只打印计划。确认无误后加 -Apply 才真正执行。
|
||||
|
||||
.EXAMPLE
|
||||
# 先看会发生什么(不改任何文件)
|
||||
.\tools\Rename-Archives.ps1
|
||||
|
||||
.EXAMPLE
|
||||
# 真正执行
|
||||
.\tools\Rename-Archives.ps1 -Apply
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$BackupListPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupList.txt'),
|
||||
[string]$BackupDir,
|
||||
[string]$ConfigPath,
|
||||
[switch]$Apply
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
Import-Module (Join-Path $projectRoot 'Common.psm1') -Force
|
||||
|
||||
if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'BackupConfig.psd1' }
|
||||
$config = Get-BaknretConfig -Path $ConfigPath
|
||||
|
||||
if (-not $BackupDir) {
|
||||
$BackupDir = $config.BackupDir
|
||||
if (-not [System.IO.Path]::IsPathRooted($BackupDir)) { $BackupDir = Join-Path $projectRoot $BackupDir }
|
||||
}
|
||||
|
||||
$catalogPath = Resolve-CatalogPath -Configured $config.SoftwareCatalog -Root $projectRoot
|
||||
$manifestPath = Join-Path $BackupDir 'manifest.json'
|
||||
$supportedFormats = @('.7z', '.rar', '.zip', '.tar')
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackupDir)) {
|
||||
Write-Host "备份目录不存在:$BackupDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "备份目录 : $BackupDir"
|
||||
Write-Host "软件名录 : $catalogPath"
|
||||
Write-Host "清单 : $BackupListPath"
|
||||
Write-Host "模式 : $(if ($Apply) { '执行(-Apply)' } else { '试运行(默认,不改任何文件)' })"
|
||||
Write-Host ''
|
||||
|
||||
function Find-ArchiveByBaseName {
|
||||
param([string]$BaseName, [string]$Directory, [string[]]$Formats)
|
||||
return Get-ChildItem -LiteralPath $Directory -File -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $Formats } |
|
||||
Select-Object -First 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 阶段一:规划
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$manifestOld = Read-BaknretManifest -Path $manifestPath
|
||||
|
||||
$plan = @()
|
||||
$unchanged = 0
|
||||
$missingOld = 0
|
||||
$conflicts = @()
|
||||
$seenNew = @{}
|
||||
$entries = @()
|
||||
|
||||
foreach ($line in (Get-Content -LiteralPath $BackupListPath)) {
|
||||
$item = ConvertFrom-BackupListLine -Line $line
|
||||
if (-not $item) { continue }
|
||||
|
||||
# 旧名 = 对"真实源路径"跑路径命名算法。
|
||||
# 注意:清单里现在写的是软件名,直接把它丢给 Get-BackupBaseName 会得到一个
|
||||
# 恰好和软件名一模一样的"旧名"(legendary -> legendary),于是永远算不出
|
||||
# 真正的旧名。必须先解析出真实路径。
|
||||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth
|
||||
$newName = $resolved.BaseName
|
||||
|
||||
$oldNameSource = $item.Path
|
||||
if ($resolved.IsName -and $resolved.CatalogEntry) { $oldNameSource = $resolved.CatalogEntry.Path }
|
||||
$oldName = Get-BackupBaseName -RawPath $oldNameSource
|
||||
|
||||
if (-not $oldName -or -not $newName) { continue }
|
||||
|
||||
if ($seenNew.ContainsKey($newName)) {
|
||||
$conflicts += "归档名 '$newName' 被 '$($seenNew[$newName])' 和 '$($item.Path)' 同时使用"
|
||||
continue
|
||||
}
|
||||
$seenNew[$newName] = $item.Path
|
||||
|
||||
$entries += [pscustomobject]@{ Item = $item; OldName = $oldName; NewName = $newName; Resolved = $resolved }
|
||||
|
||||
if ($oldName -eq $newName) { $unchanged++; continue }
|
||||
|
||||
$oldFile = Find-ArchiveByBaseName -BaseName $oldName -Directory $BackupDir -Formats $supportedFormats
|
||||
if (-not $oldFile) { $missingOld++; continue }
|
||||
|
||||
$plan += [pscustomobject]@{
|
||||
Old = $oldFile
|
||||
New = ($newName + $oldFile.Extension)
|
||||
Item = $item
|
||||
Resolved = $resolved
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "待重命名 $($plan.Count) 个;命名未变 $unchanged 个;清单里有但归档不存在 $missingOld 个"
|
||||
|
||||
if ($plan.Count -gt 0) {
|
||||
Write-Host ''
|
||||
foreach ($entry in $plan) {
|
||||
Write-Host (" {0,10:N1} MB {1}" -f ($entry.Old.Length / 1MB), $entry.Old.Name)
|
||||
Write-Host (" -> {0}" -f $entry.New)
|
||||
}
|
||||
}
|
||||
|
||||
if ($conflicts.Count -gt 0) {
|
||||
Write-Host ''
|
||||
Write-Host '存在归档名冲突,必须先解决(否则会互相覆盖):' -ForegroundColor Red
|
||||
$conflicts | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
|
||||
if (-not $Apply) {
|
||||
Write-Host '试运行结束,未改动任何文件。确认无误后加 -Apply 执行。' -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 阶段二:重命名
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$ok = 0
|
||||
$failed = 0
|
||||
|
||||
foreach ($entry in $plan) {
|
||||
$target = Join-Path $BackupDir $entry.New
|
||||
try {
|
||||
if (Test-Path -LiteralPath $target) {
|
||||
Write-Host "跳过(目标已存在): $($entry.New)" -ForegroundColor Yellow
|
||||
$failed++
|
||||
continue
|
||||
}
|
||||
|
||||
$oldSize = $entry.Old.Length
|
||||
Rename-Item -LiteralPath $entry.Old.FullName -NewName $entry.New -ErrorAction Stop
|
||||
|
||||
$newFile = Get-Item -LiteralPath $target -ErrorAction Stop
|
||||
if ($newFile.Length -ne $oldSize) {
|
||||
throw "重命名后大小不一致($oldSize -> $($newFile.Length))"
|
||||
}
|
||||
|
||||
Write-Host "已重命名: $($entry.Old.Name) -> $($entry.New)" -ForegroundColor Green
|
||||
$ok++
|
||||
} catch {
|
||||
Write-Host "重命名失败: $($entry.Old.Name) —— $_" -ForegroundColor Red
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 阶段三:重建 manifest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$rebuilt = [ordered]@{}
|
||||
$now = (Get-Date).ToString('o')
|
||||
|
||||
foreach ($entry in $entries) {
|
||||
$file = Find-ArchiveByBaseName -BaseName $entry.NewName -Directory $BackupDir -Formats $supportedFormats
|
||||
|
||||
# 历史字段优先从新键取,其次从旧键(路径命名)取
|
||||
$previous = $null
|
||||
if ($manifestOld.items.Contains($entry.NewName)) { $previous = $manifestOld.items[$entry.NewName] }
|
||||
elseif ($manifestOld.items.Contains($entry.OldName)) { $previous = $manifestOld.items[$entry.OldName] }
|
||||
|
||||
$getPrevious = {
|
||||
param([string]$Field)
|
||||
if ($previous -and ($previous.PSObject.Properties.Name -contains $Field)) { return $previous.$Field }
|
||||
return $null
|
||||
}
|
||||
|
||||
$record = [ordered]@{
|
||||
baseName = $entry.NewName
|
||||
source = $entry.Item.Path
|
||||
resolvedSource = [Environment]::ExpandEnvironmentVariables($entry.Item.Path)
|
||||
roots = @($entry.Resolved.Sources | ForEach-Object { $_.RootName })
|
||||
catalog = $(if ($entry.Resolved.CatalogEntry) { $entry.Resolved.CatalogEntry.Path } else { $null })
|
||||
archive = $(if ($file) { $file.Name } else { $entry.NewName + '.7z' })
|
||||
action = $(if ($file) { 'backed-up' } else { 'missing-source' })
|
||||
reason = '由 tools/Rename-Archives.ps1 迁移生成'
|
||||
phase = 'migrate'
|
||||
attemptedAt = $now
|
||||
finishedAt = $now
|
||||
durationSec = $null
|
||||
exitCode = $null
|
||||
verified = $false
|
||||
warnings = $false
|
||||
attemptWarnings = $false
|
||||
encrypted = ($entry.Item.Flags -contains 'encrypt')
|
||||
sourceFiles = (& $getPrevious 'sourceFiles')
|
||||
sourceBytes = (& $getPrevious 'sourceBytes')
|
||||
archiveBytes = $(if ($file) { $file.Length } else { $null })
|
||||
sha256 = (& $getPrevious 'sha256')
|
||||
lastSuccessAt = $(if ($file) { (& $getPrevious 'lastSuccessAt') } else { (& $getPrevious 'lastSuccessAt') })
|
||||
successCount = $([int](& $getPrevious 'successCount'))
|
||||
failCount = $([int](& $getPrevious 'failCount'))
|
||||
}
|
||||
$lastRestore = & $getPrevious 'lastRestoreAt'
|
||||
if ($lastRestore) { $record['lastRestoreAt'] = $lastRestore }
|
||||
|
||||
if ($file) { $rebuilt[$entry.NewName] = $record }
|
||||
}
|
||||
|
||||
$manifestNew = [pscustomobject]@{
|
||||
schemaVersion = 1
|
||||
tool = 'BakNRet'
|
||||
updatedAt = $null
|
||||
compressor = $manifestOld.compressor
|
||||
items = $rebuilt
|
||||
}
|
||||
Write-BaknretManifest -Path $manifestPath -Manifest $manifestNew | Out-Null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 阶段四:报告
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$referenced = @($rebuilt.Keys)
|
||||
$orphans = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Extension.ToLower() -in $supportedFormats -and $_.BaseName -notin $referenced })
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "重命名完成:成功 $ok,失败 $failed;manifest 已重建,含 $($rebuilt.Count) 个条目" -ForegroundColor Green
|
||||
|
||||
if ($orphans.Count -gt 0) {
|
||||
Write-Host ''
|
||||
Write-Host '以下归档没有任何清单条目指向(恢复不到,注意别误删):' -ForegroundColor Yellow
|
||||
foreach ($orphan in $orphans) {
|
||||
Write-Host (" - {0}({1:N1} MB)" -f $orphan.Name, ($orphan.Length / 1MB)) -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
if ($failed -gt 0) { exit 1 }
|
||||
exit 0
|
||||
Reference in new issue
Block a user