# SmartNav.psm1 # Poor man's zoxide for PowerShell - frecency-based smart cd # DB: JSON at $env:USERPROFILE\.smartnav\dirs.json $Script:DBPath = "$($env:USERPROFILE)\.smartnav\dirs.json" # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- function Initialize-SmartNav { $dir = Split-Path $Script:DBPath if (-not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory | Out-Null } if (-not (Test-Path $Script:DBPath)) { '[]' | Set-Content $Script:DBPath -Encoding UTF8 } } function Read-NavDB { Initialize-SmartNav $raw = Get-Content $Script:DBPath -Raw -Encoding UTF8 if ([string]::IsNullOrWhiteSpace($raw)) { return @() } try { $parsed = $raw | ConvertFrom-Json # ConvertFrom-Json returns a single object when the array has 1 item, # so force it to always be an array. return @($parsed) } catch { Write-Warning "SmartNav: DB corrupt, resetting. ($($_.Exception.Message))" '[]' | Set-Content $Script:DBPath -Encoding UTF8 return @() } } function Write-NavDB { param([object[]]$Entries) $Entries | ConvertTo-Json -Depth 3 | Set-Content $Script:DBPath -Encoding UTF8 } # Frecency score: combines visit count and how recent the last visit was. # Returns a float so Sort-Object can rank properly. function Get-Frecency { param( [int]$Score, [string]$LastAccess # ISO 8601 string ) try { $age = (Get-Date) - [datetime]$LastAccess $hoursSince = $age.TotalHours } catch { $hoursSince = 999999 } # Decay buckets (mirrors zoxide's rough weighting) $weight = switch ($true) { ($hoursSince -lt 1) { 4.0 } ($hoursSince -lt 24) { 2.0 } ($hoursSince -lt 168) { 0.5 } # < 1 week default { 0.25 } } return [math]::Round($Score * $weight, 4) } # --------------------------------------------------------------------------- # Public functions # --------------------------------------------------------------------------- function Update-NavScore { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return } if (-not (Test-Path $Path)) { return } $entries = Read-NavDB $now = (Get-Date).ToString('o') $existing = $entries | Where-Object { $_.path -eq $Path } if ($existing) { $existing.score = $existing.score + 1 $existing.last_access = $now } else { $entries += [PSCustomObject]@{ path = $Path score = 1 last_access = $now } } Write-NavDB -Entries $entries } function Get-NavMatch { param([string]$Query) $entries = Read-NavDB if ($entries.Count -eq 0) { return $null } # 1. Try regex match on path components $escaped = [regex]::Escape($Query) $matched = $entries | Where-Object { try { $_.path -match $escaped } catch { $false } } # 2. Fallback: plain substring (case-insensitive) if (-not $matched) { $matched = $entries | Where-Object { $_.path -like "*$Query*" } } if (-not $matched) { return $null } # 3. Sort by frecency, pick best $best = $matched | ForEach-Object { [PSCustomObject]@{ path = $_.path frecency = Get-Frecency -Score $_.score -LastAccess $_.last_access } } | Sort-Object -Property frecency -Descending | Select-Object -First 1 # 4. Validate the path still exists on disk if ($best -and (Test-Path $best.path)) { return $best.path } # 5. Path is stale - remove it and return null $cleaned = $entries | Where-Object { $_.path -ne $best.path } Write-NavDB -Entries @($cleaned) return $null } # --------------------------------------------------------------------------- # cd override # --------------------------------------------------------------------------- function cd { param( [Parameter(Position = 0)] [string]$Path ) # No args: go home if (-not $Path) { Push-Location $env:USERPROFILE Update-NavScore $PWD.Path return } # "-": go back if ($Path -eq '-') { Pop-Location Update-NavScore $PWD.Path return } # "~": explicit home if ($Path -eq '~') { Push-Location $env:USERPROFILE Update-NavScore $PWD.Path return } # Actual path: navigate directly if (Test-Path $Path) { Push-Location $Path Update-NavScore $PWD.Path return } # Fuzzy match: search history $match = Get-NavMatch $Path if ($match) { Write-Host " -> $match" -ForegroundColor DarkGray Push-Location $match Update-NavScore $PWD.Path } else { Write-Host "No matches for '$Path'" -ForegroundColor Red } } # --------------------------------------------------------------------------- # Bonus: list top visited dirs (like zoxide query -l) # --------------------------------------------------------------------------- function Get-NavHistory { param( [int]$Top = 20, [string]$Filter = '' ) $entries = Read-NavDB if ($entries.Count -eq 0) { Write-Host "No history yet." -ForegroundColor DarkGray return } $results = $entries | ForEach-Object { [PSCustomObject]@{ frecency = Get-Frecency -Score $_.score -LastAccess $_.last_access score = $_.score path = $_.path } } | Where-Object { $_.path -like "*$Filter*" } | Sort-Object -Property frecency -Descending | Select-Object -First $Top $results | Format-Table -AutoSize } # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- Export-ModuleMember -Function cd, Get-NavHistory, Update-NavScore, Get-NavMatch