改造前:全仓 6/6 个源文件在 5.1 上解析失败(README 却承诺支持 5.1)。原因是文件是无 BOM 的 UTF-8,而 5.1 没有 BOM 就按 ANSI 代码页解码源码,中文变乱码、全角问号吃掉引号,整块语法塌掉。 现在 28/28 个文件在 5.1 与 7 上都解析零错误,E2E 36 项在 5.1 上全绿。 顺带修掉一个被 5.1 掩盖的缺陷:带 [CmdletBinding()] 的脚本在 5.1 上,param() 默认值里 拿不到 $PSScriptRoot(实测为空串,7 上正常)。于是 Backup.ps1 / Restore.ps1 在 5.1 上不传 路径参数就报错退出 —— 而计划任务恰恰不传。E2E 之所以看不见,是因为它总是显式传路径。 8 处默认值全部移到 param() 之后的解析段,沿用本仓库对 -BackupDir 一直在用的写法。 新增三条可重放的约定,让编码不再是一次性动作: .gitattributes 接管行尾(本机 core.autocrlf=true,会把工作区改成 CRLF 制造伪 diff) .editorconfig 用 charset = utf-8-bom 锁住 BOM tools\Set-SourceEncoding.ps1 是规范化脚本,tools\Invoke-* 之外的任何改动之后都能重放 test.ps1 是唯一验收入口:Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍 test.ps1 的 Encode 层直接检查"必须有 BOM"这条规则。加它的原因很实际:实测本仓库用的 编辑工具在保存时会悄悄去掉 BOM,而丢了 BOM 的文件只在 5.1 上出错、在 7 上完全正常, 没有这条检查就会一直漏过去。 已知未修(下一步处理):5.1 上 Unit 有 6 项、Smoke 有 1 项失败,全部源于测试夹具写 临时文件时没指定编码(5.1 的 Set-Content 默认 ANSI),与产品代码无关。
341 lines
13 KiB
PowerShell
341 lines
13 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
把归档名对齐到当前清单规则,并重建 manifest.json。
|
||
|
||
.DESCRIPTION
|
||
归档名由清单条目决定:
|
||
|
||
* 软件名条目 -> 归档名 = 软件名(`Edge.7z`);
|
||
* 手写路径条目 -> 归档名 = `<末级名>_from_<上级路径>`(`FooClolor_from_C_+Programs.7z`)。
|
||
|
||
条目写法变过(把软件名改成手写路径、改名、合并条目……)之后,磁盘上的旧归档名就与当前
|
||
规则对不上了 —— 那样的归档恢复不到,会被当成孤儿。这个脚本负责把它们搬过去。
|
||
|
||
做法:
|
||
1. 遍历清单条目,算出**当前规则下的目标名**,以及一组**候选旧名**
|
||
(路径命名算法 / 名录里的软件名 / manifest 里记过的归档名);
|
||
2. 目标名已经存在就跳过;否则在候选旧名里找实际存在的归档;
|
||
3. 重命名(不是复制,同卷上是元数据操作,不搬数据);
|
||
4. 重建 manifest.json,把旧记录的历史字段(成功次数、SHA256 等)迁过去;
|
||
5. 比对重命名前后的文件大小做完整性自检。
|
||
|
||
默认是**试运行**,只打印计划。确认无误后加 -Apply 才真正执行。
|
||
|
||
.EXAMPLE
|
||
# 先看会发生什么(不改任何文件)
|
||
.\tools\Rename-Archives.ps1
|
||
|
||
.EXAMPLE
|
||
# 真正执行
|
||
.\tools\Rename-Archives.ps1 -Apply
|
||
#>
|
||
|
||
[CmdletBinding()]
|
||
param(
|
||
[string]$BackupListPath,
|
||
[string]$BackupDir,
|
||
[string]$ConfigPath,
|
||
[switch]$Apply
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
|
||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||
|
||
# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上,
|
||
# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带
|
||
# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值
|
||
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。
|
||
if (-not $BackupListPath) { $BackupListPath = Join-Path $projectRoot 'BackupList.txt' }
|
||
|
||
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
|
||
|
||
# manifest 里的历史归档名按 source / resolvedSource 建索引:
|
||
# 条目写法改过(软件名 -> 手写路径、改名、合并)之后,键对不上了,
|
||
# 但"这条清单行原本指向哪儿"通常还留在这两个字段里,靠它才能把旧归档接上。
|
||
$manifestBySource = @{}
|
||
foreach ($key in @($manifestOld.items.Keys)) {
|
||
$record = $manifestOld.items[$key]
|
||
if (-not $record) { continue }
|
||
|
||
$archiveName = $key
|
||
if (($record.PSObject.Properties.Name -contains 'archive') -and $record.archive) {
|
||
$archiveName = [System.IO.Path]::GetFileNameWithoutExtension([string]$record.archive)
|
||
}
|
||
|
||
foreach ($field in 'source', 'resolvedSource', 'catalog') {
|
||
if (-not ($record.PSObject.Properties.Name -contains $field)) { continue }
|
||
$value = [string]$record.$field
|
||
if ([string]::IsNullOrWhiteSpace($value)) { continue }
|
||
$mapKey = $value.Trim().ToLower()
|
||
if (-not $manifestBySource.ContainsKey($mapKey)) { $manifestBySource[$mapKey] = @() }
|
||
$manifestBySource[$mapKey] += $archiveName
|
||
}
|
||
}
|
||
|
||
$plan = @()
|
||
$unchanged = 0
|
||
$missingOld = 0
|
||
$conflicts = @()
|
||
$seenNew = @{}
|
||
$entries = @()
|
||
|
||
foreach ($line in (Get-Content -LiteralPath $BackupListPath)) {
|
||
$item = ConvertFrom-BackupListLine -Line $line
|
||
if (-not $item) { continue }
|
||
|
||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth
|
||
$newName = $resolved.BaseName
|
||
if (-not $newName) { continue }
|
||
|
||
if ($seenNew.ContainsKey($newName)) {
|
||
$conflicts += "归档名 '$newName' 被 '$($seenNew[$newName])' 和 '$($item.Path)' 同时使用"
|
||
continue
|
||
}
|
||
$seenNew[$newName] = $item.Path
|
||
|
||
# 候选旧名(按可能性排序):
|
||
# 1. 路径命名算法(对名录条目要用 Slot 的 Path,直接拿软件名算出来的是错的);
|
||
# 2. 名录里的软件名(旧规则:归档名 = 软件名);
|
||
# 3. manifest 里为这条记录记过的归档名。
|
||
$candidates = @()
|
||
|
||
$pathSource = $item.Path
|
||
if ($resolved.IsName) {
|
||
$slots = @($resolved.CatalogEntry.Slots)
|
||
$pathSource = if ($slots.Count -eq 1) { $slots[0].Declared } else { $null }
|
||
}
|
||
if ($pathSource) {
|
||
$derived = Get-BackupBaseName -RawPath $pathSource
|
||
if ($derived) { $candidates += $derived }
|
||
}
|
||
if ($resolved.IsName) {
|
||
$candidates += (Format-CatalogName -Name $item.Path)
|
||
}
|
||
if ($manifestOld.items.Contains($newName)) {
|
||
$recorded = $manifestOld.items[$newName]
|
||
if (($recorded.PSObject.Properties.Name -contains 'archive') -and $recorded.archive) {
|
||
$candidates += [System.IO.Path]::GetFileNameWithoutExtension([string]$recorded.archive)
|
||
}
|
||
}
|
||
|
||
# manifest 里"指向过同一个源"的历史归档名
|
||
$lookupKeys = @([string]$item.Path)
|
||
foreach ($entryItem in @($resolved.Items)) {
|
||
if ($entryItem.Declared) { $lookupKeys += [string]$entryItem.Declared }
|
||
if ($entryItem.RealPath) { $lookupKeys += [string]$entryItem.RealPath }
|
||
}
|
||
foreach ($lookupKey in $lookupKeys) {
|
||
if ([string]::IsNullOrWhiteSpace($lookupKey)) { continue }
|
||
$mapKey = $lookupKey.Trim().ToLower()
|
||
if ($manifestBySource.ContainsKey($mapKey)) { $candidates += @($manifestBySource[$mapKey]) }
|
||
}
|
||
|
||
$candidates = @($candidates | Where-Object { $_ -and $_ -ne $newName } | Select-Object -Unique)
|
||
|
||
$entries += [pscustomobject]@{ Item = $item; NewName = $newName; Resolved = $resolved; Candidates = $candidates }
|
||
|
||
if (Find-ArchiveByBaseName -BaseName $newName -Directory $BackupDir -Formats $supportedFormats) {
|
||
$unchanged++
|
||
continue
|
||
}
|
||
|
||
$oldFile = $null
|
||
foreach ($candidate in $candidates) {
|
||
$foundCandidate = Find-ArchiveByBaseName -BaseName $candidate -Directory $BackupDir -Formats $supportedFormats
|
||
if ($foundCandidate) { $oldFile = $foundCandidate; break }
|
||
}
|
||
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] }
|
||
else {
|
||
foreach ($candidate in $entry.Candidates) {
|
||
if ($manifestOld.items.Contains($candidate)) { $previous = $manifestOld.items[$candidate]; break }
|
||
}
|
||
}
|
||
|
||
$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.Items | ForEach-Object { $_.TopName } | Select-Object -Unique)
|
||
layouts = @($entry.Resolved.Items | ForEach-Object {
|
||
[ordered]@{ name = $_.ArchivePath; kind = $(if ($_.IsFile) { 'file' } else { 'dir' }) }
|
||
})
|
||
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 = [bool]$entry.Resolved.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
|