<# .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