Functions/GenXdev.Coding/Add-LineToREADME.ps1
|
<############################################################################## Part of PowerShell module : GenXdev.Coding Original cmdlet filename : Add-LineToREADME.ps1 Original author : René Vaessen / GenXdev Version : 3.28.2026 ################################################################################ Copyright (c) 2026 René Vaessen / GenXdev Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ################################################################################> # Don't remove this line [dontrefactor] ############################################################################### <# .SYNOPSIS Adds a line to a README.md markdown file in a specified section. .DESCRIPTION Finds and modifies a README.md file by adding a new line to a specified section. Can create the section if it doesn't exist. Supports formatting lines as code blocks and showing the modified section. Will look in current directory first, then walk up directories to find the README location. If not found, will use the README in the PowerShell profile directory. .PARAMETER Line The line of text to add to the README file. .PARAMETER Section The section header where the line should be added. .PARAMETER Code Switch to open the README in Visual Studio Code after modification. .PARAMETER Show Switch to display the modified section after changes. .PARAMETER Done Switch to mark a todo item as completed. .PARAMETER Priority Line priority for sorting within the section. Higher values appear first. Defaults to 1. Negative values allowed. .PARAMETER SortByDate Sort lines by date (yyyyMMdd prefix) instead of priority. .PARAMETER Ascending Reverse the sort order. By default, higher priority/newer dates appear first. .PARAMETER First Limit the -Show output to the first N lines. Does not affect file storage. .PARAMETER UseHomeREADME Switch to use README in PowerShell profile directory. .PARAMETER UseOneDriveREADME Switch to use README in OneDrive directory. .EXAMPLE Add-LineToREADME -Line "New feature" -Section "## Features" .EXAMPLE Add-LineToREADME "High prio item" "## Issues" -Priority 5 #> function Add-LineToREADME { [CmdletBinding()] param( ####################################################################### [Parameter( Mandatory = $false, ValueFromRemainingArguments = $false, HelpMessage = 'The line to add to the README' )] [string] $Line = '', ####################################################################### [Parameter( Position = 0, Mandatory = $true, HelpMessage = 'The section to add the line to' )] [ValidateNotNullOrEmpty()] [string] $Section, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Open in Visual Studio Code after modifying' )] [switch] $Code, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Show the modified section' )] [switch] $Show, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Mark the item as completed' )] [switch] $Done, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Use README in PowerShell profile directory' )] [switch] $UseHomeREADME, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Use README in OneDrive directory' )] [switch] $UseOneDriveREADME, ####################################################################### [Parameter( Position = 1, Mandatory = $false, HelpMessage = 'Priority for sorting (higher = shown first, default 1)' )] [int] $Priority = 1, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Sort lines by date (yyyyMMdd prefix) instead of priority' )] [switch] $SortByDate, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Reverse the sort order (ascending instead of descending)' )] [switch] $Ascending, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Limit -Show output to the first N lines' )] [int] $First = 0, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Show only completed (☒) items when used with -Show' )] [switch] $OnlyCompleted, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Exclude completed (☒) items when used with -Show' )] [switch] $ExcludeCompleted, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'Archive completed items from this section' )] [switch] $ArchiveCompleted, ####################################################################### [Parameter( Mandatory = $false, HelpMessage = 'File path for archiving completed items externally' )] [string] $ArchiveFilePath ) begin { # helper function to find readme path by walking up directories function findReadMePath([string] $startDir) { $startDir = GenXdev\Expand-Path $startDir Microsoft.PowerShell.Management\Push-Location -LiteralPath $startDir try { # walk up directories looking for README.md $currentDir = (Microsoft.PowerShell.Management\Get-Location).Path while ($true) { if ([System.IO.File]::Exists("$currentDir\README.md")) { return "$currentDir\README.md" } # Get parent directory $parent = GenXdev\Expand-Path "$currentDir\..\" # Stop if we're at the root (parent equals current) if ($parent -eq $currentDir) { break } # Move up to parent $currentDir = $parent } } finally { Microsoft.PowerShell.Management\Pop-Location } # fallback to onedrive readme return "$((GenXdev\Expand-Path -FilePath "$(GenXdev\Get-KnownFolderPath -KnownFolder 'OneDrive')\README.md" -CreateDirectory))"; } # determine which readme file to use if ($UseHomeREADME) { $readmePath = GenXdev\Expand-Path ` -FilePath "$([IO.Path]::GetDirectoryName($Profile))\README.md" } elseif ($UseOneDriveREADME) { $readmePath = GenXdev\Expand-Path ` -FilePath "$(GenXdev\Get-KnownFolderPath -KnownFolder 'OneDrive')\README.md" ` -CreateDirectory } else { $readmePath = findReadMePath $PWD } Microsoft.PowerShell.Utility\Write-Verbose "Using README at: $readmePath" # verify readme exists if (![IO.File]::Exists($readmePath)) { throw "README file not found at: $readmePath" } # load readme content and detect line endings $readme = [IO.File]::ReadAllText($readmePath) $lineEnding = if ($readme.IndexOf("`r`n") -ge 0) { "`r`n" } else { "`n" } # Migrate existing lines to unified emoji prefix format # Converts old prefixes (- [X], - [☒], - [ ], * ) to - ☐ / - ☒ # and adds [P1] priority if missing function MigrateReadmeLines([ref] $readmeRef, [string] $lf) { $knownSections = @( '## Features', '## Ideas', '## Issues', '## Todoos', '## Release notes' ) $content = $readmeRef.Value $migrated = $false foreach ($sec in $knownSections) { $secStart = $content.IndexOf( $sec, [System.StringComparison]::OrdinalIgnoreCase ) if ($secStart -lt 0) { continue } $secContentStart = $secStart + $sec.Length + $lf.Length # Guard: if section header is the last content without a # trailing newline, skip — nothing to migrate if ($secContentStart -ge $content.Length) { continue } $secLevel = ($sec -replace '^([#]+).*$', '$1').Length $rest = $content.Substring($secContentStart) $nextHd = [regex]::Match($rest, "(?m)^#{1,$secLevel}\s") $secEnd = if ($nextHd.Success) { $secContentStart + $nextHd.Index } else { $content.Length } $secLines = $content.Substring( $secContentStart, $secEnd - $secContentStart ) -split $lf $newLines = [System.Collections.Generic.List[string]]::new() foreach ($ln in $secLines) { if ([string]::IsNullOrWhiteSpace($ln)) { $null = $newLines.Add($ln) continue } # Already in new format? Skip if ($ln -match '^- (☐|☒) \[P') { $null = $newLines.Add($ln) continue } # Extract existing priority if present $existingPrio = if ($ln -match '\[P(-?\d+)\]') { "[P$($matches[1])]" } else { '[P1]' } # Strip any existing prefix $restText = $ln # Old completed patterns if ($ln -match '^- \[X\] ') { $restText = $ln -replace '^- \[X\] ', '' $restText = $restText -replace '\[P-?\d+\] ', '' $ln = "- ☒ $existingPrio $restText" $migrated = $true } elseif ($ln -match '^- \[☒\] ') { $restText = $ln -replace '^- \[☒\] ', '' $restText = $restText -replace '\[P-?\d+\] ', '' $ln = "- ☒ $existingPrio $restText" $migrated = $true } # Old uncompleted patterns elseif ($ln -match '^- \[ \] ') { $restText = $ln -replace '^- \[ \] ', '' $restText = $restText -replace '\[P-?\d+\] ', '' $ln = "- ☐ $existingPrio $restText" $migrated = $true } elseif ($ln -match '^- ☐ ') { $restText = $ln -replace '^- ☐ ', '' $restText = $restText -replace '\[P-?\d+\] ', '' $ln = "- ☐ $existingPrio $restText" $migrated = $true } elseif ($ln -match '^\* ') { $restText = $ln -replace '^\* ', '' $restText = $restText -replace '\[P-?\d+\] ', '' $ln = "- ☐ $existingPrio $restText" $migrated = $true } $null = $newLines.Add($ln) } if ($migrated) { $newSecContent = $newLines -join $lf $content = $content.Substring(0, $secContentStart) + $newSecContent + $content.Substring($secEnd) } } $readmeRef.Value = $content } MigrateReadmeLines ([ref] $readme) $lineEnding # -OnlyCompleted and -ExcludeCompleted are mutually exclusive if ($OnlyCompleted -and $ExcludeCompleted) { throw '-OnlyCompleted and -ExcludeCompleted are mutually exclusive.' + ' Use one or neither.' } [int] $insertIndex = $readme.IndexOf( $Section, [System.StringComparison]::OrdinalIgnoreCase ) # create section if it doesn't exist if ($insertIndex -lt 0) { $insertIndex = $readme.Length if ($readme.Length -gt 0 -and !$readme.EndsWith("$lineEnding$lineEnding")) { $readme += $lineEnding + $lineEnding } $readme += "$Section$lineEnding" $insertIndex = $readme.Length } else { $insertIndex += $Section.Length # If section has no trailing newline (e.g., at end of file), # append one so the insertion point stays within bounds if ($insertIndex -ge $readme.Length) { $readme += $lineEnding $insertIndex = $readme.Length } else { $insertIndex += $lineEnding.Length } } } process { if (![string]::IsNullOrWhiteSpace($Line)) { if ($Done) { # Find the section boundaries $sectionStart = $readme.IndexOf( $Section, [System.StringComparison]::OrdinalIgnoreCase ) if ($sectionStart -ge 0) { $sectionContentStart = $sectionStart + $Section.Length + $lineEnding.Length # Find next heading at same or higher level to # determine section boundaries $sectionLevel = ($Section -replace '^([#]+).*$', '$1').Length $restOfFile = $readme.Substring($sectionContentStart) $headingPattern = "(?m)^#{1,$sectionLevel}\s" $nextHeading = [regex]::Match($restOfFile, $headingPattern) $sectionEnd = if ($nextHeading.Success) { $sectionContentStart + $nextHeading.Index } else { $readme.Length } $sectionContent = $readme.Substring( $sectionContentStart, $sectionEnd - $sectionContentStart ) # Find lines matching the wildcard pattern $lines = $sectionContent -split $lineEnding $found = $false $matchCount = 0 # Build search pattern: - ☐ *<user's Line wildcard> # Use * to match the [Px] priority & optional date prefix $searchPattern = ('- ☐ *' + $Line) for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -like $searchPattern) { $matchCount++ $found = $true # Replace ☐ with ☒ to mark as done $lines[$i] = $lines[$i].Replace('☐', '☒') } } if (-not $found) { Microsoft.PowerShell.Utility\Write-Warning ("No line matching '" + $searchPattern + "' found in section '$Section'") } elseif ($matchCount -gt 1) { Microsoft.PowerShell.Utility\Write-Warning ("$matchCount lines matched '" + $searchPattern + "' in section '$Section'") } if ($found) { $newSectionContent = $lines -join $lineEnding $readme = $readme.Substring(0, $sectionContentStart) + $newSectionContent + $readme.Substring($sectionEnd) } } else { Microsoft.PowerShell.Utility\Write-Warning ` "Section '$Section' not found for -Done operation" } } else { # Check for duplicate before inserting $sectionStart = $readme.IndexOf( $Section, [System.StringComparison]::OrdinalIgnoreCase ) if ($sectionStart -ge 0) { $sectionContentStart = $sectionStart + $Section.Length + $lineEnding.Length $sectionLevel = ($Section -replace '^([#]+).*$', '$1').Length $rest = $readme.Substring($sectionContentStart) $nextHeading = [regex]::Match( $rest, "(?m)^#{1,$sectionLevel}\s") $sectionEnd = if ($nextHeading.Success) { $sectionContentStart + $nextHeading.Index } else { $readme.Length } $sectionContent = $readme.Substring( $sectionContentStart, $sectionEnd - $sectionContentStart) $dupLine = "- ☐ [P$Priority] $Line" if ($sectionContent -split $lineEnding -ccontains $dupLine) { Microsoft.PowerShell.Utility\Write-Warning ("Duplicate line '$Line' in " + "section '$Section'") return } } # insert new line at section with priority prefix $insertedText = "- ☐ [P$Priority] $Line$lineEnding" $readme = $readme.Substring(0, $insertIndex) + $insertedText + $readme.Substring($insertIndex) # Track insert position for pipeline input ordering (F11 fix) $insertIndex += $insertedText.Length } } } end { # Archive completed items if requested if ($ArchiveCompleted) { $sectionStart = $readme.IndexOf( $Section, [System.StringComparison]::OrdinalIgnoreCase ) if ($sectionStart -ge 0) { $sectionContentStart = $sectionStart + $Section.Length + $lineEnding.Length $sectionLevel = ($Section -replace '^([#]+).*$', '$1').Length $restOfFile = $readme.Substring($sectionContentStart) $headingPattern = "(?m)^#{1,$sectionLevel}\s" $nextHeading = [regex]::Match($restOfFile, $headingPattern) $sectionEnd = if ($nextHeading.Success) { $sectionContentStart + $nextHeading.Index } else { $readme.Length } $sectionContent = $readme.Substring( $sectionContentStart, $sectionEnd - $sectionContentStart ) $lines = $sectionContent -split $lineEnding $completedLines = [System.Collections.Generic.List[string]]::new() $incompleteLines = [System.Collections.Generic.List[string]]::new() foreach ($ln in $lines) { if ($ln -match '^\s*-\s*☒') { $null = $completedLines.Add($ln) } else { $null = $incompleteLines.Add($ln) } } if ($completedLines.Count -eq 0) { Microsoft.PowerShell.Utility\Write-Verbose ( "No completed items found in section '$Section'") } else { $datePrefix = [DateTime]::Now.ToString('yyyyMMdd') if ($ArchiveFilePath) { # Archive to external file $archivePath = GenXdev\Expand-Path ` -FilePath $ArchiveFilePath -CreateDirectory $archiveHeader = "${lineEnding}### Archived from" + " '$Section' on $datePrefix${lineEnding}" $archiveContent = ($completedLines -join $lineEnding) + $lineEnding [System.IO.File]::AppendAllText( $archivePath, $archiveHeader + $archiveContent ) # Remove completed lines from section $newSectionContent = $incompleteLines -join $lineEnding $readme = $readme.Substring(0, $sectionContentStart) + $newSectionContent + $readme.Substring($sectionEnd) # Migrate previously archived items from same-README # archive section (if any) to the external file, then # remove the now-empty archive section. $migrateSectionHeading = "$Section - Archive" $prevArchiveIdx = $readme.IndexOf( $migrateSectionHeading, [System.StringComparison]::OrdinalIgnoreCase ) if ($prevArchiveIdx -ge 0) { $prevContentStart = $prevArchiveIdx + $migrateSectionHeading.Length + $lineEnding.Length $prevSectionLevel = ($Section -replace '^([#]+).*$', '$1').Length $prevRest = $readme.Substring($prevContentStart) $prevHeadingPat = "(?m)^#{1,$prevSectionLevel}\s" $prevNext = [regex]::Match( $prevRest, $prevHeadingPat) $prevEnd = if ($prevNext.Success) { $prevContentStart + $prevNext.Index } else { $readme.Length } # Append previously archived items to # the external file. $prevContent = $readme.Substring( $prevContentStart, $prevEnd - $prevContentStart ).TrimEnd() if ($prevContent.Length -gt 0) { $migrateHeader = "${lineEnding}### Migrated" + " from '$migrateSectionHeading' on" + " $datePrefix${lineEnding}" [System.IO.File]::AppendAllText( $archivePath, $migrateHeader + $prevContent + $lineEnding ) } # Remove the archive section (heading + # content) from the README. $readme = $readme.Substring(0, $prevArchiveIdx) + $readme.Substring($prevEnd) } } else { # Move to same-README archive section $archiveSection = "$Section - Archive" # Remove completed lines from original section $newSectionContent = $incompleteLines -join $lineEnding $readme = $readme.Substring(0, $sectionContentStart) + $newSectionContent + $readme.Substring($sectionEnd) # Find or create archive section $archiveIndex = $readme.IndexOf( $archiveSection, [System.StringComparison]::OrdinalIgnoreCase ) if ($archiveIndex -lt 0) { if ($readme.Length -gt 0 -and !$readme.EndsWith("$lineEnding$lineEnding")) { $readme += $lineEnding + $lineEnding } $readme += "$archiveSection$lineEnding" $archiveIndex = $readme.Length } else { $archiveIndex += $archiveSection.Length + $lineEnding.Length } # Prepend date to each archived line $archivedLines = $completedLines | Microsoft.PowerShell.Core\ForEach-Object { $_ -replace '^- (☒) ', "- `$1 $datePrefix --> " } $archiveInsert = ($archivedLines -join $lineEnding) + $lineEnding $readme = $readme.Substring(0, $archiveIndex) + $archiveInsert + $readme.Substring($archiveIndex) } } } } # Sort lines within the section by [Px] priority (descending) # only do this for insert operations (not -Done), and only for # sections that currently exist if (!$Done -and $insertIndex -gt 0) { $sectionStart = $readme.IndexOf( $Section, [System.StringComparison]::OrdinalIgnoreCase ) if ($sectionStart -ge 0) { $sectionContentStart = $sectionStart + $Section.Length + $lineEnding.Length $sectionLevel = ($Section -replace '^([#]+).*$', '$1').Length $restOfFile = $readme.Substring($sectionContentStart) $headingPattern = "(?m)^#{1,$sectionLevel}\s" $nextHeading = [regex]::Match($restOfFile, $headingPattern) $sectionEnd = if ($nextHeading.Success) { $sectionContentStart + $nextHeading.Index } else { $readme.Length } $sectionContent = $readme.Substring( $sectionContentStart, $sectionEnd - $sectionContentStart ) # Parse and sort lines $sectionLines = $sectionContent -split $lineEnding $sortedLines = if ($SortByDate) { # Sort by date prefix (yyyyMMdd) — lines without # a date go to the end (or beginning with -Ascending) $sectionLines | Microsoft.PowerShell.Utility\Sort-Object { if ($_ -match '(\d{8})\s*-->') { $matches[1] } else { if ($Ascending) { '00000000' } else { '99999999' } } } -Descending:(-not $Ascending) -Stable } else { # Sort by [Px] priority $sectionLines | Microsoft.PowerShell.Utility\Sort-Object { if ($_ -match '\[P(-?\d+)\]') { [int]$matches[1] } else { 0 } } -Descending:(-not $Ascending) -Stable } $sortedContent = $sortedLines -join $lineEnding $readme = $readme.Substring(0, $sectionContentStart) + $sortedContent + $readme.Substring($sectionEnd) } } # save changes atomically to prevent corruption GenXdev\Write-TextFileAtomic -FilePath $readmePath -Data $readme # open in vscode if requested if ($Code) { GenXdev\VSCode $readmePath } # show modified section if requested if ($Show) { $sectionLines = [System.Collections.Generic.List[string]]::new() $inSection = $false foreach ($readmeLine in [IO.File]::ReadAllLines($readmePath)) { if ($readmeLine.StartsWith($Section)) { $inSection = $true continue } if ($inSection) { $sectionLevel = ($Section -replace '^([#]+).*$', '$1').Length if ($readmeLine.StartsWith('#')) { $lineLevel = ($readmeLine -replace '^([#]+).*$', '$1').Length if ($lineLevel -le $sectionLevel) { break } } $trimmed = $readmeLine.Trim("`r`n`t ") if ($trimmed) { $null = $sectionLines.Add($trimmed) } } } # Filter by completion status if requested if ($OnlyCompleted) { $sectionLines = [System.Collections.Generic.List[string]]( @($sectionLines | Microsoft.PowerShell.Core\Where-Object { $_ -match '^\s*-\s*☒' }) ) } elseif ($ExcludeCompleted) { $sectionLines = [System.Collections.Generic.List[string]]( @($sectionLines | Microsoft.PowerShell.Core\Where-Object { $_ -match '^\s*-\s*☐' }) ) } # Sort the collected lines if ($SortByDate) { $sectionLines = [System.Collections.Generic.List[string]]( [System.Linq.Enumerable]::ToList( [System.Linq.Enumerable]::OrderBy( $sectionLines, [System.Func[string,string]] { param($ln) if ($ln -match '(\d{8})\s*-->') { $matches[1] } else { if ($Ascending) { '00000000' } else { '99999999' } } } ) ) ) if (-not $Ascending) { $sectionLines.Reverse() } } else { $sectionLines = [System.Collections.Generic.List[string]]( [System.Linq.Enumerable]::ToList( [System.Linq.Enumerable]::OrderBy( $sectionLines, [System.Func[string,int]] { param($ln) if ($ln -match '\[P(-?\d+)\]') { [int]$matches[1] } else { 0 } } ) ) ) if (-not $Ascending) { $sectionLines.Reverse() } } # Apply -First limit (only to output, not file) if ($First -gt 0 -and $sectionLines.Count -gt $First) { $sectionLines = [System.Collections.Generic.List[string]]( $sectionLines.GetRange(0, $First) ) } $ansiColorStart = [char]27 + '[' $ansiColorYellow = "${ansiColorStart}33m" $ansiBackgroundColorBlue = "${ansiColorStart}44m" $ansiColorReset = "${ansiColorStart}0m" $sectionHeader = $Section.Trim("`r`n`t ") "$ansiBackgroundColorBlue${ansiColorYellow}${sectionHeader}${ansiColorReset}`r`n`r`n" + ($sectionLines -join "`r`n`r`n") + "`r`n`r`n" } } } |