From effc38fdd3aff35040854ea24b274a33e230d41d Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sun, 27 Sep 2026 21:30:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(tui):=20=E5=90=8D=E5=BD=95=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=BE=AA=E7=8E=AF=20+=20=E6=8C=82=E8=BF=9B?= =?UTF-8?q?=20Edit-Config=EF=BC=88=E7=AC=AC=E4=B8=89=E8=BD=AE=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E5=9D=97=C2=B7=E5=AE=8C=E6=88=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 装配:Get-BakNRetCatalogField → 菜单选字段 → Read-BakNRetLine → Set-BakNRetCatalogField → Save-BakNRetConfigFile(校验通过才原子替换 + 时间戳备份)。 菜单里**只列可编辑的字段**(单行的 Encrypt / Description):把只读的 Path / Exclude 也列出来再拒绝,会让每个只读项浪费用户一次回车。 校验用模块自己的 Import-BaknretDataFile:这份名录有 $( ) 动态表达式与跨行拼接,普通数据文件读取器读不了 —— 而正是它保证"改完整份文件还能被程序读回来"(值能读回来才算改成功)。定位用 (软件名, Slot, 字段名) 三元组,因为同一软件可以有多个 Slot。 Edit-Config 的三个界面(清单 / 设置 / 名录)至此全部完成。 判据 9 条:改一个值后只动一行、注释行数不变、改完能读回新值、留下时间戳备份且备份里是原文;取消时不写盘也不产生备份。 验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。 --- BakNRet/BakNRet.psd1 | 1 + BakNRet/BakNRet.psm1 | 3 + .../Public/Invoke-BakNRetCatalogEditor.ps1 | 92 +++++++++++++++++++ Edit-Config.ps1 | 24 ++++- tests/Run-Tests.ps1 | 33 +++++++ 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 BakNRet/Public/Invoke-BakNRetCatalogEditor.ps1 diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 index b508151..8a3e1fd 100644 --- a/BakNRet/BakNRet.psd1 +++ b/BakNRet/BakNRet.psd1 @@ -19,6 +19,7 @@ CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @( + 'Invoke-BakNRetCatalogEditor', 'Get-BakNRetCatalogField', 'Set-BakNRetCatalogField', 'Invoke-BakNRetConfigEditor', diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 index 7cbcd8b..cc42f13 100644 --- a/BakNRet/BakNRet.psm1 +++ b/BakNRet/BakNRet.psm1 @@ -178,7 +178,10 @@ . (Join-Path $PSScriptRoot 'Public\Get-BakNRetCatalogField.ps1') . (Join-Path $PSScriptRoot 'Public\Set-BakNRetCatalogField.ps1') +. (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetCatalogEditor.ps1') + Export-ModuleMember -Function @( + 'Invoke-BakNRetCatalogEditor', 'Get-BakNRetCatalogField', 'Set-BakNRetCatalogField', 'Invoke-BakNRetConfigEditor', diff --git a/BakNRet/Public/Invoke-BakNRetCatalogEditor.ps1 b/BakNRet/Public/Invoke-BakNRetCatalogEditor.ps1 new file mode 100644 index 0000000..5268105 --- /dev/null +++ b/BakNRet/Public/Invoke-BakNRetCatalogEditor.ps1 @@ -0,0 +1,92 @@ +function Invoke-BakNRetCatalogEditor { + <# + .SYNOPSIS + 名录编辑器:列出**可编辑**的 Slot 字段 → 选一个 → 输入新值 → 校验后保存(并留时间戳备份)。 + + .DESCRIPTION + 装配:Get-BakNRetCatalogField(读)→ 菜单选字段 → Read-BakNRetLine(输入新值) + → Set-BakNRetCatalogField(只改那一行)→ Save-BakNRetConfigFile(校验通过才原子替换)。 + + 菜单里**只列可编辑的字段**(单行的 Encrypt / Description):把只读的 Path / Exclude 也列出来 + 再拒绝,会让每个只读项浪费用户一次回车。可编辑面按"能不能安全往返"定,见 Get-BakNRetCatalogField。 + + 校验用模块自己的 Import-BaknretDataFile:这份名录里有 `$( )` 动态表达式与跨行拼接,普通的数据 + 文件读取器读不了 —— 而正是它保证"改完之后整份文件还能被程序读回来"(值能读回来才算改成功)。 + + 定位用 (软件名, Slot 名, 字段名) 三元组(菜单键里用 | 拼起来),因为同一软件可以有多个 Slot。 + 取消(选字段或输入时按 Esc)→ 什么都不写、也不产生备份。 + #> + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)]$Driver, + [switch]$WhatIf + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return [pscustomobject]@{ Saved = $false; Changed = $false; Error = "找不到名录文件:$Path"; BackupPath = $null } + } + + $text = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 + $fields = @(Get-BakNRetCatalogField -Text $text | Where-Object { $_.Editable }) + + if ($fields.Count -eq 0) { + return [pscustomobject]@{ Saved = $false; Changed = $false; Error = '名录里没有可编辑的字段(只有单行的 Encrypt / Description 可改)。'; BackupPath = $null } + } + + $items = @(foreach ($field in $fields) { + [pscustomobject]@{ + Key = ('{0}|{1}|{2}' -f $field.App, $field.Slot, $field.Key) + Text = ('{0,-20} {1,-12} = {2}' -f $field.App, $field.Key, $field.Value) + } + }) + + $picked = Invoke-BakNRetMenu -Title ('名录:{0}' -f $Path) -Items $items -Driver $Driver -MultiSelect $false -Hint '↑↓ 选字段 回车 改值 Esc 取消' + if ($picked.Action -ne 'confirm') { + return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ''; BackupPath = $null } + } + + $parts = @([string]$picked.Chosen.Key -split '\|') + if ($parts.Count -ne 3) { + return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ('菜单项的形状不对:{0}' -f $picked.Chosen.Key); BackupPath = $null } + } + + $field = $fields | Where-Object { $_.App -eq $parts[0] -and $_.Slot -eq $parts[1] -and $_.Key -eq $parts[2] } | Select-Object -First 1 + + Write-BakNRetAt -X 0 -Y 0 -Text ("{0}.{1}.{2} 当前是 {3};输入新值后回车(Encrypt 用 `$true / `$false,Description 用带引号的字符串),Esc 取消:" -f $parts[0], $parts[1], $parts[2], $field.Value) -ForegroundColor Cyan + $newValue = Read-BakNRetLine -Driver $Driver + if ($null -eq $newValue) { + return [pscustomobject]@{ Saved = $false; Changed = $false; Error = ''; BackupPath = $null } + } + + if ($newValue -eq $field.Value) { + return [pscustomobject]@{ Saved = $false; Changed = $false; Error = '值没有变化,没有要保存的内容。'; BackupPath = $null } + } + + $newText = Set-BakNRetCatalogField -Text $text -App $parts[0] -Slot $parts[1] -Key $parts[2] -Value $newValue + + $validate = { + param($candidate) + $probePath = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-cat-' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.psd1') + try { + [System.IO.File]::WriteAllText($probePath, $candidate, (New-Object System.Text.UTF8Encoding($true))) + $null = Import-BaknretDataFile -Path $probePath + return @() + } + catch { + return @('改完之后读不回来:' + $_.Exception.Message) + } + finally { + Remove-Item -LiteralPath $probePath -Force -ErrorAction SilentlyContinue + } + } + + $saved = Save-BakNRetConfigFile -Path $Path -Text $newText -Validate $validate ` + -BackupDirectory (Join-Path (Split-Path -Parent $Path) 'logs\config-backups') -WhatIf:$WhatIf + + return [pscustomobject]@{ + Saved = $saved.Saved + Changed = $true + Error = $saved.Error + BackupPath = $saved.BackupPath + } +} diff --git a/Edit-Config.ps1 b/Edit-Config.ps1 index b43468e..0610f7d 100644 --- a/Edit-Config.ps1 +++ b/Edit-Config.ps1 @@ -66,8 +66,18 @@ if ($Target -eq 'Settings') { exit 0 } +if ($Target -eq 'Catalog') { + $catalogPath = Join-Path $PSScriptRoot 'SoftwareCatalog.psd1' + $driver = New-BakNRetInputDriver -Script $InputScript -NonInteractive:$([bool]$InputScript) + $saved = Invoke-BakNRetCatalogEditor -Path $catalogPath -Driver $driver + if ($saved.Saved) { Write-Host ("已保存:{0}" -f $catalogPath) -ForegroundColor Green; Write-Host ("原文件备份:{0}" -f $saved.BackupPath) -ForegroundColor DarkGray; exit 0 } + if ($saved.Error) { Write-Host $saved.Error -ForegroundColor Yellow; exit 1 } + Write-Host '没有改动。' -ForegroundColor DarkGray + exit 0 +} + if ($Target) { - Write-Host ("{0} 的编辑器还没做:清单与设置已完成;名录是第三轮的工作。" -f $Target) -ForegroundColor Yellow + Write-Host ("{0} 的编辑器还没做:清单 / 设置 / 名录三个界面都已完成。" -f $Target) -ForegroundColor Yellow exit 1 } @@ -102,5 +112,15 @@ if ($picked.Chosen.Key -eq 'Settings') { exit 0 } -Write-Host ("{0} 的编辑器还没做(名录是第三轮的工作)。" -f $picked.Chosen.Key) -ForegroundColor Yellow +if ($picked.Chosen.Key -eq 'Catalog') { + $catalogPath = Join-Path $PSScriptRoot 'SoftwareCatalog.psd1' + $driver3 = New-BakNRetInputDriver -Script $InputScript -NonInteractive:$([bool]$InputScript) + $saved3 = Invoke-BakNRetCatalogEditor -Path $catalogPath -Driver $driver3 + if ($saved3.Saved) { Write-Host ("已保存:{0}" -f $catalogPath) -ForegroundColor Green; exit 0 } + if ($saved3.Error) { Write-Host $saved3.Error -ForegroundColor Yellow; exit 1 } + Write-Host '没有改动。' -ForegroundColor DarkGray + exit 0 +} + +Write-Host ("未知的目标:{0}" -f $picked.Chosen.Key) -ForegroundColor Yellow exit 1 diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index ff13288..415fb54 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -1865,6 +1865,39 @@ Test-Case '名录字段:只有单行的 Encrypt / Description 可编辑;改 } } +Test-Case '名录编辑器:脚本驱动改一个 Encrypt,只动那一行且留下备份;取消不写盘' { + $dir = Join-Path $env:TEMP ("bnr-cated-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $file = Join-Path $dir 'SoftwareCatalog.psd1' + Copy-Item -LiteralPath (Join-Path (Split-Path -Parent $PSScriptRoot) 'SoftwareCatalog.psd1') -Destination $file + $original = [System.IO.File]::ReadAllText($file) + $backupDir = Join-Path $dir 'logs\config-backups' + try { + # 第一个可编辑字段是 AutoDarkMode.DefaultData.Encrypt(当前 $true)→ 输入 $false + $keys = @('Enter', 'Char:$', 'Char:f', 'Char:a', 'Char:l', 'Char:s', 'Char:e', 'Enter') + $result = Invoke-BakNRetCatalogEditor -Path $file -Driver (New-BakNRetInputDriver -Script $keys) + Assert-Equal $true $result.Saved '应当保存' + $before = @($original -split "`n") + $after = @([System.IO.File]::ReadAllText($file) -split "`n") + Assert-Equal 2 @(Compare-Object -ReferenceObject $before -DifferenceObject $after).Count '只应当有一行不同' + Assert-Equal @($before | Where-Object { $_.TrimStart().StartsWith('#') }).Count @($after | Where-Object { $_.TrimStart().StartsWith('#') }).Count '注释行数不变' + $nowValue = ((Get-BakNRetCatalogField -Text ([System.IO.File]::ReadAllText($file)) | Where-Object { $_.App -eq 'AutoDarkMode' -and $_.Slot -eq 'DefaultData' -and $_.Key -eq 'Encrypt' }).Value) + Assert-Equal '$false' $nowValue '改完能读回新值' + Assert-True (Test-Path $result.BackupPath) '应当留下时间戳备份' + Assert-Equal $original ([System.IO.File]::ReadAllText($result.BackupPath)) '备份里是原文' + + # 取消(选字段时 Esc):不写盘、不产生新备份 + $countBefore = @(Get-ChildItem $backupDir -File | Measure-Object).Count + $contentBefore = [System.IO.File]::ReadAllText($file) + $cancelled = Invoke-BakNRetCatalogEditor -Path $file -Driver (New-BakNRetInputDriver -Script @('Esc')) + Assert-Equal $false $cancelled.Saved '取消不该保存' + Assert-Equal $contentBefore ([System.IO.File]::ReadAllText($file)) '取消后内容不变' + Assert-Equal $countBefore @(Get-ChildItem $backupDir -File | Measure-Object).Count '取消不该新增备份' + } 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