From 8c018533c962b0ee45b70c86129c56ac640e1814 Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sun, 27 Sep 2026 21:25:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(tui):=20=E5=90=8D=E5=BD=95=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E7=9A=84=E8=AF=BB=E6=94=B9=E6=A0=B8=E5=BF=83=EF=BC=88?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E8=BD=AE=E7=AC=AC=E4=B8=80=E5=9D=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先侦察事实(232 行、71 个 Slot 级字段):**可编辑面比预想的还窄,边界落在一条可判定的规则上** —— Encrypt 19 个全是单行(可编辑),Description 24 个单行 + 1 个跨行(Edge 那条多行说明,只读),Path / Exclude 一律只读(Scoop 三条带 $(if ($env:SCOOP_GLOBAL){...}) 表达式、Edge 的 Exclude 是跨行拼接)。 规则:**只有单行的 Encrypt / Description 可编辑** —— 与 ADR-0013 的"按能不能安全往返来定"一致:"改一行"对动态表达式与跨行拼接没有明确含义。 扫描时跳过块注释:文件头部模板里有 SoftWareName = @{ ... } 示例,纯文本扫描会把它当成一个软件(实测踩到)。定位靠 (软件名, Slot, 字段名) 三元组且要求唯一命中,命中 0 个或多个都抛错。 实测五个事实:认出 70 个字段、可编辑 43 个(19+24)、往返**逐字节相同 = True**、真改一个只动一行、Edge 的跨行 Description 正确地只读。 过程里两处自己的坑都被门禁逮住:插入的测试代码带过一行手滑垃圾(Parse 层当场报),以及函数文档注释里写了块注释的闭合符号 —— 它提前结束了那段注释,后半句变成代码,而它是**合法语法**所以解析层抓不到、跑到才炸(与"左边空的赋值"同族)。 验收:test.ps1 9/9 全绿(5.1 与 7)。 --- BakNRet/BakNRet.psd1 | 2 + BakNRet/BakNRet.psm1 | 5 ++ BakNRet/Public/Get-BakNRetCatalogField.ps1 | 60 ++++++++++++++++++++++ BakNRet/Public/Set-BakNRetCatalogField.ps1 | 42 +++++++++++++++ tests/Run-Tests.ps1 | 47 +++++++++++++++++ 5 files changed, 156 insertions(+) create mode 100644 BakNRet/Public/Get-BakNRetCatalogField.ps1 create mode 100644 BakNRet/Public/Set-BakNRetCatalogField.ps1 diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 index 1039b73..b508151 100644 --- a/BakNRet/BakNRet.psd1 +++ b/BakNRet/BakNRet.psd1 @@ -19,6 +19,8 @@ CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @( + 'Get-BakNRetCatalogField', + 'Set-BakNRetCatalogField', 'Invoke-BakNRetConfigEditor', 'Read-BakNRetLine', 'Get-BakNRetConfigSetting', diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 index 625da38..7cbcd8b 100644 --- a/BakNRet/BakNRet.psm1 +++ b/BakNRet/BakNRet.psm1 @@ -175,7 +175,12 @@ . (Join-Path $PSScriptRoot 'Public\Invoke-BakNRetConfigEditor.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BakNRetCatalogField.ps1') +. (Join-Path $PSScriptRoot 'Public\Set-BakNRetCatalogField.ps1') + Export-ModuleMember -Function @( + 'Get-BakNRetCatalogField', + 'Set-BakNRetCatalogField', 'Invoke-BakNRetConfigEditor', 'Read-BakNRetLine', 'Get-BakNRetConfigSetting', diff --git a/BakNRet/Public/Get-BakNRetCatalogField.ps1 b/BakNRet/Public/Get-BakNRetCatalogField.ps1 new file mode 100644 index 0000000..71f71d3 --- /dev/null +++ b/BakNRet/Public/Get-BakNRetCatalogField.ps1 @@ -0,0 +1,60 @@ +function Get-BakNRetCatalogField { + <# + .SYNOPSIS + 把名录文本拆成"Slot 级字段"的表:行号、软件名、Slot 名、字段名、原文值、是否可编辑。 + + .DESCRIPTION + 可编辑面按**能不能安全往返**来定(ADR-0013): + + * `Encrypt` / `Description`,且值在**一行之内** → 可编辑(真实名录里是 19 + 24 个); + * `Path` / `Exclude` / `Include` → 一律只读:它们常带 `$( )` 动态表达式(Scoop 那三条 + 的 `$(if ($env:SCOOP_GLOBAL) { ... })`)与**跨行字符串拼接**(Edge 的 Exclude), + "改一行"对它们没有明确含义; + * `Description` 也可能是跨行的(Edge 那条多行说明)→ 那种同样只读。 + + 文件头部的注释模板里有 `SoftWareName = @{ ... }` 这样的**示例**:纯文本扫描会把它当成一个 + 软件,所以扫描时显式跳过块注释里的内容。注意本文档里**不能**写那对块注释符号的闭合形式: + 它会提前结束上面这段注释(实测踩到:后半句变成了代码,而它是合法语法 —— 等于调用一个 + 同名命令,所以解析层抓不到,只有跑到那一步才炸)。 + #> + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text) + + $lines = $Text -split "`r?`n" + $inComment = $false + $app = '' + $slot = '' + $rows = @() + + for ($index = 0; $index -lt $lines.Count; $index++) { + $line = $lines[$index] + $trimmed = $line.Trim() + + # 块注释:整段跳过(否则模板里的示例会被当成真条目) + if ($trimmed.StartsWith('<#')) { $inComment = $true; continue } + if ($trimmed.StartsWith('#>')) { $inComment = $false; continue } + if ($inComment) { continue } + if ($trimmed -eq '' -or $trimmed.StartsWith('#')) { continue } + + if ($line -match '^ ([\w''\-]+)\s*=\s*@\{\s*$') { $app = $Matches[1]; $slot = ''; continue } + if ($line -match '^ ([\w''\-]+)\s*=\s*@\{\s*$') { $slot = $Matches[1]; continue } + if ($line -match '^\s{8}\}') { $slot = ''; continue } + if ($line -match '^\s{4}\}') { $app = ''; continue } + + if ($slot -and $line -match '^\s{12}([\w]+)\s*=\s*(.+)$') { + $key = $Matches[1] + $value = $Matches[2] + $singleLine = $value -match "^('.*'|(\`$true|\`$false))\s*$" + $rows += [pscustomobject]@{ + Line = $index + 1 + App = $app + Slot = $slot + Key = $key + Value = $value + Editable = (($key -eq 'Encrypt' -or $key -eq 'Description') -and $singleLine) + Raw = $line + } + } + } + + return @($rows) +} diff --git a/BakNRet/Public/Set-BakNRetCatalogField.ps1 b/BakNRet/Public/Set-BakNRetCatalogField.ps1 new file mode 100644 index 0000000..a86e9b0 --- /dev/null +++ b/BakNRet/Public/Set-BakNRetCatalogField.ps1 @@ -0,0 +1,42 @@ +function Set-BakNRetCatalogField { + <# + .SYNOPSIS + 改**一个**可编辑的 Slot 字段(`Encrypt` / `Description`):只动那一行、只动 `=` 之后的部分。 + + .DESCRIPTION + 定位靠 (软件名, Slot 名, 字段名) 三元组,并要求**唯一命中**:名录里同一个软件可以有多个 + Slot,只用字段名会把它们混起来;命中 0 个或命中多个都抛错 —— 静默选一个等于替你改了别处。 + + 不可编辑的字段(`Path` / `Exclude` / `Include`,以及跨行的 `Description`)一律拒绝并说明 + 原因:那类值是动态表达式或跨行拼接,"改一行"没有明确含义(ADR-0013)。 + + 本函数只处理字符串;写盘由调用方走 Save-BakNRetConfigFile(校验通过才原子替换 + 时间戳备份)。 + #> + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text, + [Parameter(Mandatory = $true)][string]$App, + [Parameter(Mandatory = $true)][string]$Slot, + [Parameter(Mandatory = $true)][string]$Key, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value + ) + + $hits = @(Get-BakNRetCatalogField -Text $Text | Where-Object { $_.App -eq $App -and $_.Slot -eq $Slot -and $_.Key -eq $Key }) + + if ($hits.Count -eq 0) { + throw ('名录里找不到这个字段:{0}.{1}.{2}' -f $App, $Slot, $Key) + } + if ($hits.Count -gt 1) { + throw ('名录里 {0}.{1}.{2} 命中 {3} 处,不敢猜是哪一个' -f $App, $Slot, $Key, $hits.Count) + } + if (-not $hits[0].Editable) { + throw ('{0}.{1}.{2} 不是可编辑字段(只有单行的 Encrypt / Description 可以改:Path 与 Exclude 常带 $( ) 表达式或跨行拼接,"改一行"对它们没有明确含义)' -f $App, $Slot, $Key) + } + + $lines = @($Text -split "`r?`n") + $original = $lines[$hits[0].Line - 1] + $equalsIndex = $original.IndexOf('=') + if ($equalsIndex -lt 0) { throw ('这一行没有 = :{0}' -f $original) } + + $lines[$hits[0].Line - 1] = $original.Substring(0, $equalsIndex + 1) + ' ' + $Value + return ($lines -join "`n") +} diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index ca0c13e..ff13288 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -1818,6 +1818,53 @@ Test-Case '配置编辑器:脚本驱动改一个值,只动那一行且留下 } } +Test-Case '名录字段:只有单行的 Encrypt / Description 可编辑;改一个只动那一行;往返逐字节等价' { + $catalogPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'SoftwareCatalog.psd1' + $text = Get-Content -LiteralPath $catalogPath -Raw -Encoding UTF8 + $fields = @(Get-BakNRetCatalogField -Text $text) + + Assert-True ($fields.Count -ge 60) ('应当认出足够多的 Slot 字段,实际 ' + $fields.Count) + # 头部注释模板里的 SoftWareName 示例必须被排除(块注释要跳过) + Assert-Equal 0 @($fields | Where-Object { $_.App -eq 'SoftWareName' }).Count '模板示例不该被当成软件' + + # 可编辑面的规则:只有单行的 Encrypt / Description + $editable = @($fields | Where-Object { $_.Editable }) + Assert-True ($editable.Count -ge 40) ('可编辑字段应当有四十多个,实际 ' + $editable.Count) + Assert-Equal 0 @($editable | Where-Object { $_.Key -notin @('Encrypt', 'Description') }).Count '只有 Encrypt / Description 可编辑' + Assert-Equal 0 @($fields | Where-Object { $_.Key -eq 'Path' -and $_.Editable }).Count 'Path 一律只读(常带 $() 表达式)' + Assert-Equal 0 @($fields | Where-Object { $_.Key -eq 'Exclude' -and $_.Editable }).Count 'Exclude 一律只读(Edge 那条是跨行拼接)' + # Edge 的 Description 是跨行的:必须只读 + $edgeDescription = @($fields | Where-Object { $_.App -eq 'MicrosoftEdge' -and $_.Key -eq 'Description' }) + Assert-Equal 1 $edgeDescription.Count 'Edge 的 Description 应当认出来' + Assert-Equal $false $edgeDescription[0].Editable '跨行的 Description 必须只读' + + # 往返:把每个可编辑字段设成它当前的值,文本必须逐字节相同 + $roundTripped = $text + foreach ($field in $editable) { + $roundTripped = Set-BakNRetCatalogField -Text $roundTripped -App $field.App -Slot $field.Slot -Key $field.Key -Value $field.Value + } + Assert-Equal $text $roundTripped '每个可编辑字段设成当前值后文本逐字节相同' + + # 真改一个(AutoDarkMode 的 Encrypt),只动那一行、注释行数不变、改完能读回新值 + $changed = Set-BakNRetCatalogField -Text $text -App 'AutoDarkMode' -Slot 'DefaultData' -Key 'Encrypt' -Value '$false' + $before = @($text -split "`n") + $after = @($changed -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 '注释行数不变' + Assert-Equal '$false' ((Get-BakNRetCatalogField -Text $changed | Where-Object { $_.App -eq 'AutoDarkMode' -and $_.Key -eq 'Encrypt' }).Value) '改完能读回新值' + + # 拒绝:不可编辑的字段、跨行的 Description、不存在的字段,都必须抛错 + foreach ($bad in @( + @{ A = 'AutoDarkMode'; S = 'DefaultData'; K = 'Path' }, + @{ A = 'MicrosoftEdge'; S = 'DefaultData'; K = 'Description' }, + @{ A = 'NoSuchApp'; S = 'NoSuchSlot'; K = 'Encrypt' } + )) { + $threw = $false + try { Set-BakNRetCatalogField -Text $text -App $bad.A -Slot $bad.S -Key $bad.K -Value 'x' | Out-Null } catch { $threw = $true } + Assert-True $threw ('应当拒绝:' + $bad.A + '.' + $bad.S + '.' + $bad.K) + } +} + # ============================================================================ Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue