scripts/win/system/note.ps1

# note.ps1 — BORG Notes (simple, title=filename, fzf picker)
# Storage: %APPDATA%\Borg\notes
# Commands:
# borg note add "<title>" "<description>"
# borg note search "<query>" # fzf picker; Enter -> print description
# borg note show # fzf picker; Enter -> print description
# borg note show "<title>" # direct by title; prints description
# borg note edit "<title>" # open in editor
# borg note rm "<title>" # delete note

param(
  [Parameter(Mandatory, Position=0)]
  [ValidateSet('add','search','show','edit','rm')]
  [string]$Action,

  [Parameter(Position=1)] [string]$Arg1,  # title | query
  [Parameter(Position=2)] [string]$Arg2   # description for add
)

# ---------------- Paths & init ----------------
$NotesRoot = Join-Path $env:APPDATA "Borg\notes"

function Initialize-Notes {
  if (-not (Test-Path $NotesRoot)) { New-Item -ItemType Directory -Path $NotesRoot -Force | Out-Null }
}

# ---------------- Utilities ----------------
function Sanitize-Title([string]$Title) {
  if ([string]::IsNullOrWhiteSpace($Title)) { return $null }
  $clean = $Title.Trim()
  # Replace invalid filename chars with underscore
  [char[]]$bad = [IO.Path]::GetInvalidFileNameChars()
  foreach ($c in $bad) { $clean = $clean.Replace($c, '_') }
  # Collapse whitespace
  $clean = ($clean -replace '\s+', ' ').Trim()
  return $clean
}
function Title-ToPath([string]$Title) {
  $clean = Sanitize-Title $Title
  if (-not $clean) { return $null }
  Join-Path $NotesRoot "$clean.md"
}

function Read-FrontMatter([string[]]$Lines) {
  $meta = [ordered]@{}
  if ($Lines.Count -ge 3 -and $Lines[0].Trim() -eq '---') {
    $end = $null
    for ($i=1; $i -lt $Lines.Count; $i++) { if ($Lines[$i].Trim() -eq '---') { $end = $i; break } }
    if ($end) {
      for ($j=1; $j -lt $end; $j++) {
        if ($Lines[$j] -match '^\s*([^:]+):\s*(.*)$') {
          $k = $matches[1].Trim(); $v = $matches[2].Trim()
          $meta[$k] = $v
        }
      }
    }
  }
  $meta
}
function Get-NoteBody([string[]]$Lines) {
  if ($Lines.Count -ge 3 -and $Lines[0].Trim() -eq '---') {
    $end = $null
    for ($i=1; $i -lt $Lines.Count; $i++) { if ($Lines[$i].Trim() -eq '---') { $end = $i; break } }
    if ($end -ne $null -and $end + 1 -lt $Lines.Count) { return ($Lines[($end+1)..($Lines.Count-1)] -join "`n").Trim() }
  }
  ($Lines -join "`n").Trim()
}
function Load-Note([string]$Path) {
  if (-not (Test-Path -LiteralPath $Path)) { return $null }
  $lines = Get-Content -LiteralPath $Path
  $meta  = Read-FrontMatter $lines
  $body  = Get-NoteBody $lines
  [pscustomobject]@{
    Title    = if ($meta['Title']) { $meta['Title'] } else { [IO.Path]::GetFileNameWithoutExtension($Path) }
    Created  = $meta['CreatedUtc']
    Updated  = $meta['UpdatedUtc']
    Path     = $Path
    Body     = $body
  }
}
function Get-AllNotes {
  Initialize-Notes
  Get-ChildItem -LiteralPath $NotesRoot -Filter '*.md' -File |
    ForEach-Object { Load-Note $_.FullName } |
    Where-Object { $_ -ne $null }
}

function Require-Fzf {
  if (-not (Get-Command fzf -ErrorAction SilentlyContinue)) {
    throw "fzf is required for interactive selection. Install it (winget install fzf) or use direct commands."
  }
}

function Pick-NoteWithAction([pscustomobject[]]$Items, [string]$Prompt='notes> ') {
  Require-Fzf
  if (-not $Items -or $Items.Count -eq 0) { Write-Host 'No notes.'; return $null }

  # Title<TAB>Created<TAB>Path (Path is field {3} for preview)
  $lines = $Items | ForEach-Object { "{0}`t{1}`t{2}" -f $_.Title, $_.Created, $_.Path }

  $previewCmd = 'powershell -NoProfile -Command "try { Get-Content -LiteralPath ''{3}'' -Raw } catch { Write-Output '''' }"'

  $fzfArgs = @(
    '--delimiter', "`t",
    '--with-nth=1,2',
    '--prompt', $Prompt,
    '--height', '80%',
    '--layout', 'reverse',
    '--ansi',
    '--preview', $previewCmd,
    '--preview-window', 'right,60%,border-rounded,wrap',
    '--expect', 'enter,del,backspace'
  )

  $raw = $lines | & fzf @fzfArgs
  if ([string]::IsNullOrWhiteSpace($raw)) { return $null }

  # When --expect is used, first line is the key; subsequent line(s) are selected items
  $parts = $raw -split "(`r`n|`n)"
  $key   = ($parts[0] ?? '').Trim().ToLower()
  $sel   = $parts | Where-Object { $_ -match "`t" } | Select-Object -First 1
  if ([string]::IsNullOrWhiteSpace($sel)) { return $null }

  $fields = $sel -split "`t", 3
  $selPath = $fields[2]
  $picked  = $Items | Where-Object { $_.Path -eq $selPath } | Select-Object -First 1

  # Return both the key and the picked note
  [pscustomobject]@{ Key = $key; Item = $picked }
}
function Pick-Note([pscustomobject[]]$Items, [string]$Prompt='notes> ') {
  $result = Pick-NoteWithAction $Items $Prompt
  if ($null -eq $result) { return $null }
  return $result.Item
}
# ---------------- Core ops ----------------
function Note-Add([string]$Title, [string]$Description) {
  $Title = Sanitize-Title $Title
  if (-not $Title) { throw 'Missing title. Usage: borg note add "<title>" "<description>"' }
  if ($null -eq $Description) { $Description = '' }

  Initialize-Notes

  $path = Title-ToPath $Title
  if (Test-Path -LiteralPath $path) {
    throw "A note with this title already exists: '$Title'"
  }

  $created = (Get-Date -AsUTC).ToString('o')

  $content = @(
    '---'
    "Title: $Title"
    "CreatedUtc: $created"
    "UpdatedUtc: $created"
    '---'
    ''
    $Description
  ) -join "`n"

  Set-Content -LiteralPath $path -Value $content -NoNewline -Encoding UTF8

  [pscustomobject]@{ Title=$Title; Path=$path }
}

function Note-Show([string]$TitleKey) {
  while ($true) {
    # ---- Resolve which note to show ----
    $note = $null

    if ([string]::IsNullOrWhiteSpace($TitleKey)) {
      # Interactive picker of all notes
      $picked = Pick-Note (Get-AllNotes) 'show> '
      if ($null -eq $picked) { return }

      # Support both: wrapper returns note; WithAction returns @{Item=...}
      $note = if ($picked.PSObject.Properties['Item']) { $picked.Item } else { $picked }
    }
    else {
      # Direct by title
      $path = Title-ToPath $TitleKey
      if (-not (Test-Path -LiteralPath $path)) {
        $matches = Get-AllNotes | Where-Object { $_.Title -ieq $TitleKey -or $_.Title -ilike "*$TitleKey*" }
        if ($matches.Count -gt 1) {
          $picked = Pick-Note $matches 'show> '
          if ($null -eq $picked) { return }
          $note = if ($picked.PSObject.Properties['Item']) { $picked.Item } else { $picked }
        } elseif ($matches.Count -eq 1) {
          $note = $matches[0]
        } else {
          throw "Note not found by title: '$TitleKey'"
        }
      } else {
        $note = Load-Note $path
        if ($null -eq $note) { throw "Note not found: '$TitleKey'" }
      }
    }

    # ---- Display the note and present actions ----
    Clear-Host
    Write-Host ("=== {0} ===" -f $note.Title) -ForegroundColor Cyan
    if ($note.Created) { Write-Host ("Created: {0}" -f $note.Created) -ForegroundColor DarkGray }
    if ($note.Updated) { Write-Host ("Updated: {0}" -f $note.Updated) -ForegroundColor DarkGray }
    Write-Host ''
    Write-Host $note.Body
    Write-Host ''
    Write-Host "Options: [E]xit [B]ack to list [C]opy to clipboard"
    $choice = (Read-Host 'Choose').Trim().ToLowerInvariant()

    switch ($choice) {
      { $_ -in @('e','exit','q','quit') } { return }
      { $_ -in @('c','copy') } {
        if (CopyToClipboard $note.Body) {
          Write-Host 'Copied to clipboard.' -ForegroundColor Green
        } else {
          Write-Host 'Failed to copy to clipboard.' -ForegroundColor Red
        }
        # stay on the same note until user chooses B or E
        continue
      }
      { $_ -in @('b','back') } {
        # Go back to the list only makes sense when we came from a picker.
        # For direct title, also reopen the list for convenience.
        $TitleKey = $null
        continue
      }
      default {
        # Unknown input: re-prompt on same note
        continue
      }
    }
  }
}

function Open-NoteByPath([string]$Path) {
  if (-not (Test-Path -LiteralPath $Path)) { throw "Note file not found: $Path" }
  $editor =
    if     ($env:EDITOR)                                        { $env:EDITOR }
    elseif (Get-Command 'code'   -ErrorAction SilentlyContinue) { 'code' }
    elseif (Get-Command 'micro'  -ErrorAction SilentlyContinue) { 'micro' }
    else                                                        { 'notepad.exe' }

  $qpath = '"{0}"' -f $Path
  if ($editor -ieq 'code')       { Start-Process -FilePath 'code'       -ArgumentList @('-g', $qpath) | Out-Null }
  elseif ($editor -ieq 'micro')  { Start-Process -FilePath 'micro'      -ArgumentList @($qpath)       | Out-Null }
  elseif ($editor -ieq 'notepad.exe') { Start-Process -FilePath 'notepad.exe' -ArgumentList $qpath   | Out-Null }
  else                           { Start-Process -FilePath $editor      -ArgumentList $qpath          | Out-Null }
}
function Remove-NoteByPath([pscustomobject]$Note) {
  $title = $Note.Title
  $path  = $Note.Path
  $ans = Read-Host "Delete note '$title'? (y/N)"
  if ($ans -match '^(y|yes)$') {
    Remove-Item -LiteralPath $path -Force
    Write-Host "Removed note '$title'"
  } else {
    Write-Host "Cancelled."
  }
}
function Note-Edit([string]$TitleKey) {
  if ([string]::IsNullOrWhiteSpace($TitleKey)) { throw 'Usage: borg note edit "<title>"' }

  $path = Title-ToPath $TitleKey
  if (-not (Test-Path -LiteralPath $path)) { throw "Note not found by title: '$TitleKey'" }

  $editor =
    if     ($env:EDITOR)                                      { $env:EDITOR }
    elseif (Get-Command 'code'   -ErrorAction SilentlyContinue) { 'code' }
    elseif (Get-Command 'micro'  -ErrorAction SilentlyContinue) { 'micro' }
    else                                                        { 'notepad.exe' }

  # IMPORTANT: quote the path so spaces are preserved as a single argument
  $qpath = '"{0}"' -f $path

  if ($editor -ieq 'code') {
    Start-Process -FilePath 'code' -ArgumentList @('-g', $qpath) | Out-Null
  }
  elseif ($editor -ieq 'micro') {
    Start-Process -FilePath 'micro' -ArgumentList @($qpath) | Out-Null
  }
  elseif ($editor -ieq 'notepad.exe') {
    Start-Process -FilePath 'notepad.exe' -ArgumentList $qpath | Out-Null
  }
  else {
    # generic editor: still pass the quoted path as a single arg
    Start-Process -FilePath $editor -ArgumentList $qpath | Out-Null
  }
}

function Note-Rm([string]$TitleKey) {
  if ([string]::IsNullOrWhiteSpace($TitleKey)) { throw 'Usage: borg note rm "<title>"' }
  $path = Title-ToPath $TitleKey
  if (-not (Test-Path -LiteralPath $path)) { throw "Note not found by title: '$TitleKey'" }
  Remove-Item -LiteralPath $path -Force
  "Removed note '$TitleKey'"
}

function Note-Search([string]$Query) {
  Initialize-Notes
  $all = Get-AllNotes
  $matches = if ([string]::IsNullOrWhiteSpace($Query)) {
    $all
  } else {
    $all | Where-Object { $_.Title -match [regex]::Escape($Query) -or $_.Body -match $Query }
  }

  if (-not $matches -or $matches.Count -eq 0) { Write-Host "No matches."; return }

  $picked = Pick-NoteWithAction $matches 'search> '
  if ($null -eq $picked) { return }

  $key = $picked.Key
  $note = $picked.Item
  if ($null -eq $note) { return }

  switch ($key) {
    # Enter (or no key captured) -> open in editor
    ''       { Open-NoteByPath -Path $note.Path }
    'enter'  { Open-NoteByPath -Path $note.Path }

    # Delete or Backspace -> confirm & delete
    'del'        { Remove-NoteByPath -Note $note }
    'backspace'  { Remove-NoteByPath -Note $note }

    default { Open-NoteByPath -Path $note.Path }
  }
}

# ---------------- Dispatcher ----------------
switch ($Action) {
  'add'    { Note-Add   -Title $Arg1 -Description $Arg2 }
  'search' { Note-Search -Query $Arg1 }
  'show'   { Note-Show   -TitleKey $Arg1 }
  'edit'   { Note-Edit   -TitleKey $Arg1 }
  'rm'     { Note-Rm     -TitleKey $Arg1 }
}