Files
BakNRet/tools/Rename-Archives.ps1
T
Shuery 2d26f78d15 fix: 源文件改存 UTF-8 with BOM,让 Windows PowerShell 5.1 真正可用
改造前:全仓 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),与产品代码无关。
2026-09-26 22:05:42 +08:00

341 lines
13 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.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