PowerShellBookmark.psm1

# PowerShellBookmark - save, recall, and run your favourite commands.
# Bookmarks are stored as JSON so they are editable, portable, and syncable.
$ErrorActionPreference = 'Stop'

# ---------------- store (private helpers, non-dashed to stay out of the cmdlet namespace) ----------------

function storePath {
  if ($env:POWERSHELLBOOKMARK_PATH) { return $env:POWERSHELLBOOKMARK_PATH }
  Join-Path (Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'PowerShellBookmark') 'bookmarks.json'
}

function readStore {
  $path = storePath
  if (-not (Test-Path $path)) { return @() }
  try {
    $raw = Get-Content -Raw -Path $path
    if ([string]::IsNullOrWhiteSpace($raw)) { return @() }
    @($raw | ConvertFrom-Json)
  } catch {
    Write-Warning "Could not read bookmark store '$path': $($_.Exception.Message)"
    @()
  }
}

function writeStore {
  param([Parameter(Mandatory)]$Bookmarks)
  $path = storePath
  $dir = Split-Path -Parent $path
  if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
  , @($Bookmarks) | ConvertTo-Json -Depth 6 | Set-Content -Path $path -Encoding UTF8
}

# A named mutex serialises read-modify-write across concurrent windows, so two
# PowerShell windows saving at the same time cannot clobber each other's changes.
function enterStoreLock {
  $mutex = [System.Threading.Mutex]::new($false, 'PowerShellBookmark_' + [Math]::Abs((storePath).GetHashCode()))
  $held = $false
  try { $held = $mutex.WaitOne(5000) } catch [System.Threading.AbandonedMutexException] { $held = $true }
  [pscustomobject]@{ Mutex = $mutex; Held = $held }
}
function exitStoreLock {
  param($Lock)
  if ($Lock) {
    if ($Lock.Held) { try { $Lock.Mutex.ReleaseMutex() } catch { Write-Verbose 'mutex release skipped' } }
    $Lock.Mutex.Dispose()
  }
}

# Atomically bump a bookmark's usage counter (called after a run).
function bumpUsage {
  param([Parameter(Mandatory)][string]$Name)
  $lock = enterStoreLock
  try {
    $store = @(readStore)
    $b = $store | Where-Object name -eq $Name | Select-Object -First 1
    if ($b) { $b.useCount = [int]$b.useCount + 1; $b.lastUsed = (Get-Date).ToString('o'); writeStore $store }
  }
  finally { exitStoreLock $lock }
}

function makeBookmark {
  param($Name, $Command, $Tag, $Description)
  [pscustomobject]@{
    name        = $Name
    command     = $Command
    tags        = @($Tag | Where-Object { $_ })
    description  = $Description
    created     = (Get-Date).ToString('o')
    lastUsed    = $null
    useCount    = 0
  }
}

function frecencySort {
  param($Bookmarks)
  @($Bookmarks) | Sort-Object `
    @{ Expression = { [int]$_.useCount }; Descending = $true }, `
    @{ Expression = { if ($_.lastUsed) { [datetime]$_.lastUsed } else { [datetime]::MinValue } }; Descending = $true }, `
    name
}

# Placeholders use a DOUBLE-brace syntax {{name}} so they don't clash with PowerShell's own { } (script blocks, hashtables).
function placeholders {
  param([string]$Command)
  [regex]::Matches($Command, '\{\{\s*([^}]+?)\s*\}\}') | ForEach-Object { $_.Groups[1].Value.Trim() } | Select-Object -Unique
}

function expandPlaceholders {
  param([string]$Command, [hashtable]$Values)
  $out = $Command
  foreach ($k in $Values.Keys) {
    $out = [regex]::Replace($out, '\{\{\s*' + [regex]::Escape($k) + '\s*\}\}', [string]$Values[$k])
  }
  $out
}

# Interactive picker: Out-ConsoleGridView -> fzf -> numbered menu, whichever is available.
function pickBookmark {
  param($Bookmarks)
  $items = @($Bookmarks)
  if (-not $items) { return $null }
  if (Get-Command Out-ConsoleGridView -ErrorAction Ignore) {
    $chosen = $items |
      Select-Object name, command, @{ n = 'tags'; e = { $_.tags -join ',' } }, useCount |
      Out-ConsoleGridView -Title 'Bookmarks' -OutputMode Single
    if ($chosen) { return $items | Where-Object name -eq $chosen.name | Select-Object -First 1 }
    return $null
  }
  if (Get-Command fzf -ErrorAction Ignore) {
    $line = $items | ForEach-Object { '{0}`t{1}' -f $_.name, $_.command } | fzf --with-nth=1,2 --delimiter="`t"
    if ($line) { $n = ($line -split "`t")[0]; return $items | Where-Object name -eq $n | Select-Object -First 1 }
    return $null
  }
  # fallback: numbered menu
  for ($i = 0; $i -lt $items.Count; $i++) {
    Write-Host ('[{0}] {1,-18} {2}' -f ($i + 1), $items[$i].name, $items[$i].command)
  }
  $sel = Read-Host 'Select a number (Enter to cancel)'
  $n = 0
  if ([int]::TryParse($sel, [ref]$n) -and $n -ge 1 -and $n -le $items.Count) { return $items[$n - 1] }
  $null
}

# Generic string picker (for history lines): Out-ConsoleGridView -> fzf -> numbered menu.
function pickText {
  param([string[]]$Items, [string]$Title = 'Select')
  $arr = @($Items)
  if (-not $arr) { return $null }
  if (Get-Command Out-ConsoleGridView -ErrorAction Ignore) { return $arr | Out-ConsoleGridView -Title $Title -OutputMode Single }
  if (Get-Command fzf -ErrorAction Ignore) { return ($arr | fzf) }
  for ($i = 0; $i -lt $arr.Count; $i++) { Write-Host ('[{0}] {1}' -f ($i + 1), $arr[$i]) }
  $sel = Read-Host 'Select a number (Enter to cancel)'
  $n = 0
  if ([int]::TryParse($sel, [ref]$n) -and $n -ge 1 -and $n -le $arr.Count) { return $arr[$n - 1] }
  $null
}

# Recent commands, most-recent first, de-duplicated, capped at $Count.
# -AllSessions reads PowerShell's PERSISTENT PSReadLine history (survives window close / restart);
# otherwise it uses the current session's Get-History.
function getHistoryCommands {
  param([switch]$AllSessions, [int]$Count = 50)
  if ($AllSessions) {
    $path = (Get-PSReadLineOption).HistorySavePath
    if ($path -and (Test-Path $path)) {
      $lines = [System.Collections.ArrayList]@(Get-Content -Path $path -ErrorAction SilentlyContinue)
      $lines.Reverse()
      return @($lines | Where-Object { $_ -and $_.Trim() } | Select-Object -Unique | Select-Object -First $Count)
    }
    Write-Warning 'No persistent PSReadLine history found; using this session only.'
  }
  $h = @(Get-History | Select-Object -ExpandProperty CommandLine)
  [array]::Reverse($h)
  @($h | Where-Object { $_ } | Select-Object -Unique | Select-Object -First $Count)
}

# ---------------- public cmdlets ----------------

function Save-Bookmark {
  <#
  .SYNOPSIS Save a command as a named bookmark (favourite).
  .EXAMPLE Save-Bookmark push 'git push origin main' -Tag git
  .EXAMPLE git push origin main # then:
           Save-Bookmark -Last -Name push
  #>

  [CmdletBinding(SupportsShouldProcess)]
  param(
    [Parameter(Position = 0)][string]$Name,
    [Parameter(Position = 1)][string]$Command,
    [string[]]$Tag,
    [string]$Description,
    [switch]$Last,
    [switch]$FromHistory,
    [switch]$AllSessions,
    [int]$Count = 50,
    [switch]$Force
  )
  if ($Last) {
    $Command = (Get-History | Select-Object -Last 1).CommandLine
    if (-not $Command) { throw 'No previous command in history to bookmark.' }
  }
  elseif ($FromHistory) {
    $recent = getHistoryCommands -AllSessions:$AllSessions -Count $Count
    if (-not $recent) { throw 'No command history available.' }
    $Command = pickText -Items $recent -Title 'Pick a command to bookmark'
    if (-not $Command) { return }
  }
  if (-not $Command) { Write-Warning "Provide a command, or use -Last / -FromHistory. Example: bms push 'git push origin main'"; return }
  if (-not $Name) { $Name = Read-Host 'Name for this bookmark' }
  if (-not $Name) { throw 'A name is required.' }

  $lock = enterStoreLock
  try {
    $store = @(readStore)
    if (($store | Where-Object name -eq $Name) -and -not $Force) {
      throw "A bookmark named '$Name' already exists. Use -Force to overwrite."
    }
    if ($PSCmdlet.ShouldProcess($Name, 'Save bookmark')) {
      writeStore (@($store | Where-Object name -ne $Name) + (makeBookmark -Name $Name -Command $Command -Tag $Tag -Description $Description))
      Write-Host "Saved bookmark '$Name'." -ForegroundColor Green
    }
  }
  finally { exitStoreLock $lock }
}

function Get-Bookmark {
  <#
  .SYNOPSIS List or search bookmarks (sorted by frecency - most used and most recent first).
  .EXAMPLE Get-Bookmark
  .EXAMPLE Get-Bookmark -Tag git
  #>

  [CmdletBinding()]
  param([Parameter(Position = 0)][string]$Name, [string[]]$Tag, [string]$Search)
  $store = readStore
  if ($Name) { $store = $store | Where-Object { $_.name -like $Name } }
  if ($Tag) { $store = $store | Where-Object { $t = $_.tags; @($Tag | Where-Object { $t -contains $_ }).Count -gt 0 } }
  if ($Search) { $store = $store | Where-Object { $_.command -like "*$Search*" -or $_.name -like "*$Search*" -or ((@($_.tags) -join ' ') -like "*$Search*") } }
  frecencySort $store | ForEach-Object { if ($_) { $_.PSObject.TypeNames.Insert(0, 'PowerShellBookmark.Bookmark') }; $_ }
}

function Invoke-Bookmark {
  <#
  .SYNOPSIS Run a bookmark by name, or pick one interactively. Fills any {{placeholders}} first.
  .EXAMPLE Invoke-Bookmark push
  .EXAMPLE bm # opens the picker
  .EXAMPLE Invoke-Bookmark checkout -Print # just return the resolved command, do not run
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Justification = 'Running the user''s own saved command is the purpose of this cmdlet.')]
  [CmdletBinding()]
  param([Parameter(Position = 0)][string]$Name, [switch]$Print, [switch]$Copy)
  $store = @(readStore)
  if (-not $store) { Write-Warning 'No bookmarks yet. Save one with Save-Bookmark.'; return }

  $bm = $null
  if ($Name) {
    $bm = $store | Where-Object name -eq $Name | Select-Object -First 1
    if (-not $bm) {
      $hits = @($store | Where-Object { $_.name -like "*$Name*" -or $_.command -like "*$Name*" })
      if ($hits.Count -eq 1) { $bm = $hits[0] } elseif ($hits.Count -gt 1) { $bm = pickBookmark $hits }
    }
  }
  if (-not $bm) { $bm = pickBookmark (frecencySort $store) }
  if (-not $bm) { return }

  $cmd = $bm.command
  $ph = placeholders $cmd
  if ($ph) {
    $vals = @{}
    foreach ($p in $ph) { $vals[$p] = Read-Host $p }
    $cmd = expandPlaceholders -Command $cmd -Values $vals
  }

  bumpUsage -Name $bm.name

  if ($Print) { return $cmd }
  if ($Copy) {
    if (Get-Command Set-Clipboard -ErrorAction Ignore) { Set-Clipboard -Value $cmd; Write-Host "Copied to clipboard: $cmd" -ForegroundColor Green }
    else { Write-Warning 'Set-Clipboard is not available here; returning the command instead.'; Write-Output $cmd }
    return
  }
  Write-Host "> $cmd" -ForegroundColor Cyan
  Invoke-Expression $cmd
}

function Remove-Bookmark {
  <#
  .SYNOPSIS Delete a bookmark by name.
  .EXAMPLE Remove-Bookmark push
  #>

  [CmdletBinding(SupportsShouldProcess)]
  param([Parameter(Mandatory, Position = 0)][string]$Name)
  $lock = enterStoreLock
  try {
    $store = @(readStore)
    if (-not ($store | Where-Object name -eq $Name)) { Write-Warning "No bookmark named '$Name'."; return }
    if ($PSCmdlet.ShouldProcess($Name, 'Remove bookmark')) {
      writeStore (@($store | Where-Object name -ne $Name))
      Write-Host "Removed '$Name'." -ForegroundColor Yellow
    }
  }
  finally { exitStoreLock $lock }
}

function Enable-BookmarkKeys {
  <#
  .SYNOPSIS Wire PSReadLine keys: a picker key, and a "save the current line" key.
  .DESCRIPTION Add "Import-Module PowerShellBookmark; Enable-BookmarkKeys" to your $PROFILE.
  .EXAMPLE Enable-BookmarkKeys
  .EXAMPLE Enable-BookmarkKeys -PickerKey 'Ctrl+j' -SaveKey 'Alt+b'
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Binds more than one key.')]
  [CmdletBinding()]
  param([string]$PickerKey = 'Ctrl+b', [string]$SaveKey = 'Alt+s')
  if (-not (Get-Module PSReadLine)) { Write-Warning 'PSReadLine is not loaded; bookmark keys are unavailable.'; return }

  Set-PSReadLineKeyHandler -Chord $PickerKey -BriefDescription 'BookmarkPicker' -LongDescription 'Insert a saved bookmark onto the line' -ScriptBlock {
    $bm = pickBookmark (frecencySort (readStore))
    if ($bm) {
      [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine()
      [Microsoft.PowerShell.PSConsoleReadLine]::Insert($bm.command)
    }
  }
  Set-PSReadLineKeyHandler -Chord $SaveKey -BriefDescription 'BookmarkSaveLine' -LongDescription 'Save the current line as a bookmark' -ScriptBlock {
    $line = $null; $cursor = $null
    [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor)
    if ($line) {
      $name = Read-Host "`nName for bookmark"
      if ($name) { Save-Bookmark -Name $name -Command $line -Force }
      [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt()
    }
  }
  Write-Host "Bookmark keys enabled: $PickerKey = picker, $SaveKey = save current line." -ForegroundColor Green
}

function Set-Bookmark {
  <#
  .SYNOPSIS Edit an existing bookmark's command, tags, or description. Keeps its usage stats.
  .EXAMPLE Set-Bookmark push -Command 'git push origin HEAD'
  .EXAMPLE Set-Bookmark push -Tag git,daily
  #>

  [CmdletBinding(SupportsShouldProcess)]
  param(
    [Parameter(Mandatory, Position = 0)][string]$Name,
    [Parameter(Position = 1)][string]$Command,
    [string[]]$Tag,
    [string]$Description
  )
  $lock = enterStoreLock
  try {
    $store = @(readStore)
    $bm = $store | Where-Object name -eq $Name | Select-Object -First 1
    if (-not $bm) { Write-Warning "No bookmark named '$Name'."; return }
    if ($PSCmdlet.ShouldProcess($Name, 'Update bookmark')) {
      if ($PSBoundParameters.ContainsKey('Command')) { $bm.command = $Command }
      if ($PSBoundParameters.ContainsKey('Tag')) { $bm.tags = @($Tag | Where-Object { $_ }) }
      if ($PSBoundParameters.ContainsKey('Description')) { $bm.description = $Description }
      writeStore $store
      Write-Host "Updated '$Name'." -ForegroundColor Green
    }
  }
  finally { exitStoreLock $lock }
}

function Rename-Bookmark {
  <#
  .SYNOPSIS Rename a bookmark, keeping its command, tags, and usage stats.
  .EXAMPLE Rename-Bookmark push push-main
  #>

  [CmdletBinding(SupportsShouldProcess)]
  param(
    [Parameter(Mandatory, Position = 0)][string]$Name,
    [Parameter(Mandatory, Position = 1)][string]$NewName,
    [switch]$Force
  )
  $lock = enterStoreLock
  try {
    $store = @(readStore)
    $bm = $store | Where-Object name -eq $Name | Select-Object -First 1
    if (-not $bm) { Write-Warning "No bookmark named '$Name'."; return }
    if (($store | Where-Object name -eq $NewName) -and -not $Force) {
      throw "A bookmark named '$NewName' already exists. Use -Force to overwrite it."
    }
    if ($PSCmdlet.ShouldProcess("$Name -> $NewName", 'Rename bookmark')) {
      $store = @($store | Where-Object name -ne $NewName)
      $bm.name = $NewName
      writeStore $store
      Write-Host "Renamed '$Name' to '$NewName'." -ForegroundColor Green
    }
  }
  finally { exitStoreLock $lock }
}

function Get-BookmarkStorePath {
  <#
  .SYNOPSIS Return the path to the bookmarks JSON file (so you can open it in any editor).
  .EXAMPLE code (Get-BookmarkStorePath)
  #>

  [CmdletBinding()]
  param()
  storePath
}

function Show-BookmarkStore {
  <#
  .SYNOPSIS Open the bookmarks JSON file in an editor for bulk editing (rename, retag, reorder, delete many at once).
  .DESCRIPTION Edits take effect on the next bookmark command - nothing to reload. Run Test-BookmarkStore afterwards to confirm the JSON is still valid.
  .EXAMPLE Show-BookmarkStore
  .EXAMPLE Show-BookmarkStore -Editor notepad
  #>

  [CmdletBinding()]
  param([string]$Editor)
  $path = storePath
  if (-not (Test-Path $path)) {
    $dir = Split-Path -Parent $path
    if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
    Set-Content -Path $path -Value '[]' -Encoding UTF8
  }
  $ed = $Editor
  if (-not $ed) { $ed = $env:EDITOR }
  if (-not $ed -and (Get-Command code -ErrorAction Ignore)) { $ed = 'code' }
  if ($ed) { & $ed $path } else { Invoke-Item $path }
  Write-Host "Editing $path" -ForegroundColor Cyan
  Write-Host 'Bulk-edit names, tags, or commands - changes apply on your next bookmark command. Run Test-BookmarkStore to validate.' -ForegroundColor DarkGray
}

function Test-BookmarkStore {
  <#
  .SYNOPSIS Validate the bookmarks JSON and report how many bookmarks it holds (run after a bulk edit).
  .EXAMPLE Test-BookmarkStore
  #>

  [CmdletBinding()]
  param()
  $path = storePath
  if (-not (Test-Path $path)) { Write-Host "No bookmark store yet at $path." -ForegroundColor Yellow; return $true }
  try {
    $data = @((Get-Content -Raw -Path $path | ConvertFrom-Json))
    Write-Host "OK - $($data.Count) bookmark(s) in $path" -ForegroundColor Green
    $true
  } catch {
    Write-Warning "Invalid JSON in $path`: $($_.Exception.Message)"
    $false
  }
}

Set-Alias -Name bm  -Value Invoke-Bookmark
Set-Alias -Name bms -Value Save-Bookmark
Set-Alias -Name bml -Value Get-Bookmark

# Tab-complete saved bookmark names for the name-taking cmdlets (and the bm alias).
$BookmarkNameCompleter = {
  param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
  $null = $commandName, $parameterName, $commandAst, $fakeBoundParameters   # required by the completer signature
  foreach ($b in (readStore)) {
    if ($b.name -like "$wordToComplete*") {
      [System.Management.Automation.CompletionResult]::new($b.name, $b.name, 'ParameterValue', ('{0} ({1})' -f $b.name, $b.command))
    }
  }
}
Register-ArgumentCompleter -CommandName Invoke-Bookmark, Remove-Bookmark, Set-Bookmark, Rename-Bookmark, bm -ParameterName Name -ScriptBlock $BookmarkNameCompleter

Export-ModuleMember -Function Save-Bookmark, Get-Bookmark, Set-Bookmark, Rename-Bookmark, Invoke-Bookmark, Remove-Bookmark, Show-BookmarkStore, Get-BookmarkStorePath, Test-BookmarkStore, Enable-BookmarkKeys -Alias bm, bms, bml