From 59ec5a21d4baaba08815ff2606e42749e1640f33 Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sun, 27 Sep 2026 16:22:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(tui):=20=E6=A0=A1=E9=AA=8C=E5=90=8E?= =?UTF-8?q?=E5=8E=9F=E5=AD=90=E4=BF=9D=E5=AD=98=20+=20=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=88=B3=E5=A4=87=E4=BB=BD=EF=BC=88=E7=AC=AC=E4=B8=80=E8=BD=AE?= =?UTF-8?q?=E7=AC=AC=E4=BA=94=E5=9D=97=C2=B7=E5=9C=B0=E5=9F=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个配置编辑器共用的这块地基(ADR-0013)。顺序是刻意的:① 先校验(调用方给的 -Validate 回调);② **只有校验通过才备份与写盘** —— 校验没过还留下备份会让人以为"动过了";③ 原子替换走 Write-BaknretAtomicText。返回结果对象而不是抛异常:TUI 要把错误显示出来让人继续改,而不是把界面炸掉("TUI 异常不改退出码"这条同样适用)。 给 Write-BaknretAtomicText 加了一个可选 -Encoding(不传时沿用原行为,现有调用点不受影响)。原因:它原先固定用日志编码(无 BOM),而三个配置文件都是带 BOM 的 —— 丢掉 BOM 会让 5.1 把整个文件按 GBK 读,中文全成乱码。这类"编码悄悄变了"的错不会报错,只会让文件在某一个 PowerShell 版本上读出来是乱码。 判据(断言 10 条):校验不过时不写盘、不产生备份、错误原文能带回去;校验通过后新内容落盘、备份里是**原文**、且写回**保留 UTF-8 BOM**(逐字节验 EF BB BF)。 验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。 --- BakNRet/BakNRet.psd1 | 1 + BakNRet/BakNRet.psm1 | 3 ++ BakNRet/Public/Save-BakNRetConfigFile.ps1 | 55 ++++++++++++++++++++++ BakNRet/Public/Write-BaknretAtomicText.ps1 | 7 ++- tests/Run-Tests.ps1 | 27 +++++++++++ 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 BakNRet/Public/Save-BakNRetConfigFile.ps1 diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 index bd4ddb3..ac17b20 100644 --- a/BakNRet/BakNRet.psd1 +++ b/BakNRet/BakNRet.psd1 @@ -19,6 +19,7 @@ CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @( + 'Save-BakNRetConfigFile', 'Get-BakNRetBackupListRow', 'Set-BakNRetBackupListRow', 'Invoke-BakNRetMenu', diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 index ca0944a..87d32ac 100644 --- a/BakNRet/BakNRet.psm1 +++ b/BakNRet/BakNRet.psm1 @@ -160,7 +160,10 @@ . (Join-Path $PSScriptRoot 'Public\Get-BakNRetBackupListRow.ps1') . (Join-Path $PSScriptRoot 'Public\Set-BakNRetBackupListRow.ps1') +. (Join-Path $PSScriptRoot 'Public\Save-BakNRetConfigFile.ps1') + Export-ModuleMember -Function @( + 'Save-BakNRetConfigFile', 'Get-BakNRetBackupListRow', 'Set-BakNRetBackupListRow', 'Invoke-BakNRetMenu', diff --git a/BakNRet/Public/Save-BakNRetConfigFile.ps1 b/BakNRet/Public/Save-BakNRetConfigFile.ps1 new file mode 100644 index 0000000..c501f49 --- /dev/null +++ b/BakNRet/Public/Save-BakNRetConfigFile.ps1 @@ -0,0 +1,55 @@ +function Save-BakNRetConfigFile { + <# + .SYNOPSIS + 把新内容保存回配置文件:先校验,过了才做时间戳备份 + 原子替换;不过就原样不动并带回错误。 + + .DESCRIPTION + 三个配置编辑器(清单 / 配置 / 名录)共用这块地基(ADR-0013)。顺序是刻意安排的: + + 1. **先校验**(调用方给的 -Validate 回调返回错误字符串); + 2. **只有校验通过才备份与写盘** —— 校验没过还留下备份,会让人以为"动过了"; + 3. 原子替换走 Write-BaknretAtomicText(临时文件 + 替换,失败时原文件完好)。 + + 返回值是结果对象而不是抛异常:TUI 需要把错误**显示出来**让人继续改,而不是把界面炸掉。 + "TUI 的异常绝不改退出码"(ADR-0010)这条同样适用。 + + 编码默认 UTF-8 **带 BOM** —— 仓库里这三个文件都是带 BOM 的(.editorconfig 与验收的 Encode + 层都这么要求),丢掉 BOM 会让 5.1 把整个文件按 GBK 读,中文全成乱码。 + #> + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text, + [string]$BackupDirectory, + [scriptblock]$Validate, + [System.Text.Encoding]$Encoding, + [switch]$WhatIf + ) + + if (-not $Encoding) { $Encoding = New-Object System.Text.UTF8Encoding($true) } + + $errors = @() + if ($Validate) { $errors = @(& $Validate $Text | Where-Object { $_ }) } + if ($errors.Count -gt 0) { + return [pscustomobject]@{ Saved = $false; BackupPath = $null; Error = ($errors -join ';') } + } + + if (-not $BackupDirectory) { + $BackupDirectory = Join-Path (Split-Path -Parent $Path) 'logs\config-backups' + } + + $backupPath = $null + if ((Test-Path -LiteralPath $Path) -and -not $WhatIf) { + if (-not (Test-Path -LiteralPath $BackupDirectory)) { + New-Item -ItemType Directory -Path $BackupDirectory -Force | Out-Null + } + $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss') + $backupPath = Join-Path $BackupDirectory ('{0}-{1}.bak' -f [System.IO.Path]::GetFileName($Path), $stamp) + Copy-Item -LiteralPath $Path -Destination $backupPath -Force + } + + if (-not $WhatIf) { + Write-BaknretAtomicText -Path $Path -Text $Text -Encoding $Encoding | Out-Null + } + + return [pscustomobject]@{ Saved = $true; BackupPath = $backupPath; Error = '' } +} diff --git a/BakNRet/Public/Write-BaknretAtomicText.ps1 b/BakNRet/Public/Write-BaknretAtomicText.ps1 index df25d5e..a4a15d3 100644 --- a/BakNRet/Public/Write-BaknretAtomicText.ps1 +++ b/BakNRet/Public/Write-BaknretAtomicText.ps1 @@ -5,7 +5,10 @@ #> param( [Parameter(Mandatory = $true)][string]$Path, - [AllowEmptyString()][string]$Text = '' + [AllowEmptyString()][string]$Text = '', + # 默认沿用日志编码(无 BOM)。配置文件要传带 BOM 的 UTF8Encoding —— 丢掉 BOM 会让 + # Windows PowerShell 5.1 把整个文件按 GBK 读,中文全成乱码。 + [System.Text.Encoding]$Encoding ) $directory = Split-Path -Parent $Path @@ -14,7 +17,7 @@ } $temp = "$Path.tmp" - [System.IO.File]::WriteAllText($temp, $Text, $script:LogEncoding) + [System.IO.File]::WriteAllText($temp, $Text, $(if ($Encoding) { $Encoding } else { $script:LogEncoding })) if (-not (Test-Path -LiteralPath $Path)) { Move-BakNRetArchiveIntoPlace -TempPath $temp -DestinationPath $Path diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 6cffc0e..02376d5 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -1597,6 +1597,33 @@ Test-Case '清单行表与单行替换:只动那一行,注释与其它行逐 Assert-True $threw '行号越界必须抛错' } +Test-Case '保存配置:校验不过什么都不动,过了才备份 + 原子替换,且保留 BOM' { + $dir = Join-Path $env:TEMP ("bnr-save-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $file = Join-Path $dir 'BackupList.txt' + $utf8Bom = New-Object System.Text.UTF8Encoding($true) + [System.IO.File]::WriteAllText($file, "Edge`n", $utf8Bom) + try { + # 1) 校验不过:文件不动、也不产生备份(留备份会让人以为"动过了") + $bad = Save-BakNRetConfigFile -Path $file -Text "坏行`n" -Validate { param($t) @('第 1 行有问题') } -BackupDirectory (Join-Path $dir 'bak') + Assert-Equal $false $bad.Saved '校验不过不能保存' + Assert-True ($bad.Error -like '*第 1 行有问题*') '错误原文要带回来(TUI 要显示它)' + Assert-Equal "Edge`n" ([System.IO.File]::ReadAllText($file)) '文件内容不动' + Assert-True (-not (Test-Path (Join-Path $dir 'bak'))) '校验不过不该产生备份' + + # 2) 校验通过:写进去 + 备份里是原文 + 保留 BOM + $ok = Save-BakNRetConfigFile -Path $file -Text "+WindowsTerminal`n" -Validate { param($t) @() } -BackupDirectory (Join-Path $dir 'bak') + Assert-Equal $true $ok.Saved '校验通过应当保存' + Assert-Equal "+WindowsTerminal`n" ([System.IO.File]::ReadAllText($file)) '新内容写进去了' + Assert-True (Test-Path $ok.BackupPath) '应当产生时间戳备份' + Assert-Equal "Edge`n" ([System.IO.File]::ReadAllText($ok.BackupPath)) '备份里是原文' + $bytes = [System.IO.File]::ReadAllBytes($file) + Assert-True ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) '写回必须保留 UTF-8 BOM' + } finally { + Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue + } +} + # ============================================================================ Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue