scripts/win/jump/web.ps1

<#
  borg web — manage and open URL favorites (Webmarks)
  Verbs: add, list, go, rm
 
  SAFE RULES:
    - Never auto-reset store.json.
    - If store.json is invalid JSON, abort with a clear message. No writes.
    - [OnDebug] Before any write, create a timestamped backup: store.json.bak-YYYYMMDD-HHMMSS
    - Only write if in-memory config is valid and we actually changed it.
 
  Requirements:
    - $storePath should be defined by your environment/globalfn.ps1
    - fzf optional; falls back to numbered menu
#>


param(
    [Parameter(Mandatory, Position = 0)]
    [ValidateSet('add', 'list', 'go', 'rm', 'help')]
    [string]$Action,

    [Parameter(Position = 1)]
    [string]$Alias,

    [Parameter(Position = 2)]
    [string]$Url
)

# ───────────────────────────────────────────────────────────────────────────────
# Resolve store.json
# ───────────────────────────────────────────────────────────────────────────────
if (-not $script:storePath -and (Get-Variable storePath -Scope Global -ErrorAction SilentlyContinue)) {
    $script:storePath = $Global:storePath
}
if (-not $script:storePath) {
    $script:storePath = Join-Path $env:APPDATA "borg\store.json"
}
if (-not (Test-Path $script:storePath)) {
    throw "Config file not found: ${script:storePath}"
}

# ───────────────────────────────────────────────────────────────────────────────
# Helpers
# ───────────────────────────────────────────────────────────────────────────────
function Read-Config-Safely {
    try {
        $raw = Get-Content $script:storePath -Raw -Encoding UTF8
    }
    catch {
        throw "Unable to read ${script:storePath}: $($_.Exception.Message)"
    }

    if ([string]::IsNullOrWhiteSpace($raw)) {
        throw "store.json is empty. Please restore it from a backup before using 'borg web'. Path: ${script:storePath}"
    }

    try {
        $cfg = $raw | ConvertFrom-Json
    }
    catch {
        throw "store.json contains invalid JSON. Please restore/fix it first. Path: ${script:storePath}"
    }

    if ($null -eq $cfg) {
        throw "store.json parsed to null. Please restore/fix it first. Path: ${script:storePath}"
    }
    return $cfg
}

function Ensure-Webmarks-InMemory($cfg) {
    if ($cfg.PSObject.Properties.Name -notcontains 'Webmarks' -or $null -eq $cfg.Webmarks) {
        # Create an empty array **in memory only**; we will persist only on writes (add/rm).
        Add-Member -InputObject $cfg -MemberType NoteProperty -Name Webmarks -Value @() -Force
    }
    return $cfg
}

function Backup-And-Save($cfg) {
    # Make a timestamped backup before overwriting
    $stamp = Get-Date -Format "yyyyMMdd-HHmmss"
    # $bak = "${script:storePath}.bak-${stamp}"
    # try {
    # Copy-Item -LiteralPath $script:storePath -Destination $bak -Force
    # }
    # catch {
    # throw "Failed to create backup at '$bak': $($_.Exception.Message)"
    # }

    # Write atomically: temp file -> validate -> move
    $tmp = "${script:storePath}.tmp-${stamp}"
    try {
        $json = $cfg | ConvertTo-Json -Depth 20
    }
    catch {
        throw "Failed to serialize config to JSON. Aborting write."
    }

    try {
        $json | Set-Content -Path $tmp -Encoding UTF8
        # Double-check temp file can be parsed back
        $null = (Get-Content $tmp -Raw -Encoding UTF8 | ConvertFrom-Json)
    }
    catch {
        throw "Write/validate temp file failed; original left intact. Temp: $tmp"
    }

    try {
        Move-Item -LiteralPath $tmp -Destination $script:storePath -Force
    }
    catch {
        throw "Failed to replace store.json with new content. Temp remains at: $tmp"
    }
}

function Has-Fzf {
  try {
    $null = Get-Command fzf -ErrorAction Stop
    return $true
  } catch {
    return $false
  }
}

function Validate-Url([string]$u) {
    if ([string]::IsNullOrWhiteSpace($u)) { return $false }
    $uri = $null
    if ([System.Uri]::TryCreate($u, [System.UriKind]::Absolute, [ref]$uri)) {
        return ($uri.Scheme -in @('http', 'https'))
    }
    return $false
}

function Pick-Webmark($entries, [string]$prompt = "Select URL") {
  if (-not $entries -or $entries.Count -eq 0) {
    Write-Host "⚠️ No web favorites found."
    return $null
  }

  # Show as: "alias -> url"
  $display = $entries | ForEach-Object { "{0} -> {1}" -f $_.alias, $_.url }

  if (Has-Fzf) {
    $selection = $display | fzf --prompt ("{0}: " -f $prompt) --height 40% --reverse
    if (-not $selection) { return $null }
  } else {
    $i = 1
    $display | ForEach-Object { Write-Host ("{0}. {1}" -f $i++, $_) }
    $choice = Read-Host ("{0} (1-{1})" -f $prompt, $entries.Count)
    [int]$idx = 0
    if (-not [int]::TryParse($choice, [ref]$idx)) { return $null }
    if ($idx -lt 1 -or $idx -gt $entries.Count) { return $null }
    $selection = $display[$idx-1]
  }

  # Robustly map: parse the alias from "alias -> url"
  $selection = $selection.Trim()
  $selAlias  = ($selection -split '\s*->\s*', 2)[0].Trim()

  return $entries | Where-Object { $_.alias -eq $selAlias } | Select-Object -First 1
}

function Show-Help {
    @"
borg web — URL favorites
 
Usage:
  borg web add <alias> <url> # add new favorite (prompts if args missing)
  borg web list # show all saved URLs
  borg web go [alias] # pick with fzf or open alias directly
  borg web rm [alias] # remove via fzf or by alias
 
NOTE: This command refuses to write if store.json is invalid JSON.
      Fix/restore your store.json first if you see an error.
"@
 | Write-Host
}

# ───────────────────────────────────────────────────────────────────────────────
# Main
# ───────────────────────────────────────────────────────────────────────────────
try {
    $config = Read-Config-Safely
}
catch {
    Write-Host "❌ $($_.Exception.Message)" -ForegroundColor Red
    if ($Action -ne 'help') { return }
}

switch ($Action) {
    'help' { Show-Help; return }

    'list' {
        $cfg = Ensure-Webmarks-InMemory $config
        $webmarks = $cfg.Webmarks
        if (-not $webmarks -or $webmarks.Count -eq 0) {
            Write-Host "ℹ️ No web favorites."
            return
        }
        $webmarks | Sort-Object alias | Format-Table `
        @{Label = 'Alias'; Expression = { $_.alias } },
        @{Label = 'URL'; Expression = { $_.url } } -AutoSize
        return
    }

    'add' {
        $cfg = Ensure-Webmarks-InMemory $config

        # 1-arg form: borg web add <url>
        if ($Alias -and -not $Url -and (Validate-Url $Alias)) {
            $Url = $Alias
            $uriObj = $null
            [void][System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref]$uriObj)
            $hostName = if ($uriObj -and $uriObj.Host) { $uriObj.Host } else { $Url }
            $defaultAlias = $hostName
            $Alias = Read-Host ("📛 Enter alias for web favorite [{0}]" -f $defaultAlias)
            if ([string]::IsNullOrWhiteSpace($Alias)) { $Alias = $defaultAlias }
        }
        elseif (-not $Alias) {
            $Alias = Read-Host "📛 Enter alias for web favorite"
            if (-not $Alias) { Write-Host "❌ Aborted (empty alias)."; return }
        }

        if (-not $Url) {
            $Url = Read-Host "🌍 Enter URL (https://...)"
        }
        if (-not (Validate-Url $Url)) {
            Write-Host "❌ Invalid URL: $Url"
            return
        }

        if ($cfg.Webmarks | Where-Object { $_.alias -eq $Alias }) {
            Write-Host "⚠️ Alias '$Alias' already exists. Aborting."
            return
        }

        # Persist to the actual property (not a local copy)
        $cfg.Webmarks = @($cfg.Webmarks) + , ([pscustomobject]@{ alias = $Alias; url = $Url })

        try {
            Backup-And-Save $cfg
            Write-Host "✅ Added: $Alias → $Url"
        }
        catch {
            Write-Host "❌ Save failed: $($_.Exception.Message)" -ForegroundColor Red
        }
        return
    }



    'go' {
        $cfg = Ensure-Webmarks-InMemory $config
        $webmarks = $cfg.Webmarks
        $entry = $null

        if ($Alias) {
            $entry = $webmarks | Where-Object { $_.alias -eq $Alias } | Select-Object -First 1
            if (-not $entry) { Write-Host "❌ Alias not found: $Alias"; return }
        }
        else {
            $entry = Pick-Webmark $webmarks "Open"
            if (-not $entry) { return }
        }

        if (-not (Validate-Url $entry.url)) {
            Write-Host "❌ Stored URL is invalid: $($entry.url)"
            return
        }
        Start-Process $entry.url
        Write-Host "🌐 Opened: $($entry.alias) → $($entry.url)"
        return
    }

    'rm' {
        $cfg = Ensure-Webmarks-InMemory $config
        $webmarks = $cfg.Webmarks
        $entry = $null

        if ($Alias) {
            $entry = $webmarks | Where-Object { $_.alias -eq $Alias } | Select-Object -First 1
            if (-not $entry) { Write-Host "❌ Alias not found: $Alias"; return }
        }
        else {
            $entry = Pick-Webmark $webmarks "Remove"
            if (-not $entry) { return }
        }

        $confirm = Read-Host "🗑️ Remove '$($entry.alias)' → $($entry.url)? (y/n)"
        if ($confirm -notmatch '^[Yy]$') { Write-Host "↩️ Aborted."; return }

        $cfg.Webmarks = @($webmarks | Where-Object { $_.alias -ne $entry.alias })

        try {
            Backup-And-Save $cfg
            Write-Host "✅ Removed: $($entry.alias)"
        }
        catch {
            Write-Host "❌ Save failed: $($_.Exception.Message)" -ForegroundColor Red
        }
        return
    }
}