z-pushd.psm1

$safehome = if ([String]::IsNullOrWhiteSpace($Env:HOME)) { $env:USERPROFILE } else { $Env:HOME } 
$cdHistory = Join-Path -Path $safehome -ChildPath '\.cdHistory'

<#
.SYNOPSIS
Tracks your most used directories, based on 'frecency'. This is done by storing your CD command history and ranking it over time.
 
.DESCRIPTION
After a short learning phase, z will take you to the most 'frecent' directory that matches the regex given on the command line.
 
.PARAMETER JumpPath
A un-escaped regular expression of the directory name to jump to. Character escaping will be done internally.
 
.PARAMETER Option
Frecency - Match by frecency (default)
Rank - Match by rank only
Time - Match by recent access only
 
.PARAMETER OnlyCurrentDirectory
Restrict matches to subdirectories of the current directory
 
.PARAMETER Listfiles
List only, don't navigate to the directory
 
.PARAMETER Remove
Remove the current directory from the datafile
 
.PARAMETER Clean
Clean up all history entries that cannot be resolved
 
.NOTES
Current PowerShell implementation is very crude and does not yet support all of the options of the original z bash script.
Although tracking of frequently used directories is obtained through the continued use of the "cd" command, the Windows registry is also scanned for frequently accessed paths.
 
.LINK
https://github.com/letuanhai/z
 
.EXAMPLE
CD to the most frecent directory matching 'foo'
z foo
 
.EXAMPLE
CD to the most recently accessed directory matching 'foo'
z foo -o Time
#>

function z {
    param(
        [Parameter(Position = 0)]
        [string]
        ${JumpPath},

        [ValidateSet("Time", "T", "Frecency", "F", "Rank", "R")]
        [Alias('o')]
        [string]
        $Option = 'Frecency',

        [Alias('c')]
        [switch]
        $OnlyCurrentDirectory = $null,

        [Alias('l')]
        [switch]
        $ListFiles = $null,

        [Alias('x')]
        [switch]
        $Remove = $null,

        [Alias('d')]
        [switch]
        $Clean = $null

        # [Alias('p')]
        # [switch]
        # $Push = $false
    )

    if (((-not $Clean) -and (-not $Remove) -and (-not $ListFiles)) -and [string]::IsNullOrWhiteSpace($JumpPath)) { Get-Help z; return; }

    # If a valid path is passed in to z, treat it like the normal cd command
    if (-not $ListFiles -and -not [string]::IsNullOrWhiteSpace($JumpPath) -and (Test-Path $JumpPath)) {
        # if ($Push) {
        pushdX $JumpPath
        # }
        # else {
        # cdX $JumpPath
        # }
        return;
    }

    if ((Test-Path $cdHistory)) {
        if ($Remove) {
            Save-CdCommandHistory $Remove
        }
        elseif ($Clean) {
            Cleanup-CdCommandHistory
        }
        else {

            # This causes conflicts with the -Remove parameter. Not sure whether to remove registry entry.
            #$mruList = Get-MostRecentDirectoryEntries

            $providerRegex = $null

            If ($OnlyCurrentDirectory) {
                $providerRegex = (Get-FormattedLocation).replace('\', '\\')
                if (-not $providerRegex.EndsWith('\\')) {
                    $providerRegex += '\\'
                }
                $providerRegex += '.*?'
            }
            else {
                $providerRegex = Get-CurrentSessionProviderDrives ((Get-PSProvider).Drives | Select-Object -ExpandProperty Name)
            }

            $list = @()

            $global:history |
            Where-Object { Get-DirectoryEntryMatchPredicate -path $_.Path -jumpPath $JumpPath -ProviderRegex $providerRegex } | Get-ArgsFilter -Option $Option |
            ForEach-Object { if ($ListFiles -or (Test-Path $_.Path.FullName)) { $list += $_ } }

            if ($ListFiles) {

                $newList = $list | ForEach-Object { New-Object PSObject -Property  @{Rank = $_.Rank; Path = $_.Path.FullName; LastAccessed = [DateTime]$_.Time } }
                Format-Table -InputObject $newList -AutoSize

            }
            else {

                if ($list.Length -eq 0) {
                    # It's not found in the history file, perhaps it's still a valid directory. Let's check.
                    if ((Test-Path $JumpPath)) {
                        # if ($Push) {
                        pushdX $JumpPath
                        # }
                        # else {
                        # cdX $JumpPath
                        # }
                    }
                    else {
                        Write-Host "$JumpPath Not found"
                    }

                }
                else {
                    if ($list.Length -gt 1) {
                        $entry = $list | Sort-Object -Descending { $_.Score } | Select-Object -First 1

                    }
                    else {
                        $entry = $list[0]
                    }

                    # if ($Push) {
                    Push-Location $entry.Path.FullName
                    # }
                    # else {
                    # Set-Location $entry.Path.FullName
                    # }
                    Save-CdCommandHistory $Remove
                }
            }
        }
    }
    else {
        Save-CdCommandHistory $Remove
    }
}

function pushdX {
    [CmdletBinding(DefaultParameterSetName = 'Path', SupportsTransactions = $true, HelpUri = 'http://go.microsoft.com/fwlink/?LinkID=113370')]
    param(
        [Parameter(ParameterSetName = 'Path', Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        ${Path},

        [Parameter(ParameterSetName = 'LiteralPath', ValueFromPipelineByPropertyName = $true)]
        [Alias('PSPath')]
        [string]
        ${LiteralPath},

        [switch]
        ${PassThru},

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        ${StackName})

    begin {
        try {
            $outBuffer = $null
            if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer)) {
                $PSBoundParameters['OutBuffer'] = 1
            }
            $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Push-Location', [System.Management.Automation.CommandTypes]::Cmdlet)
            $scriptCmd = { & $wrappedCmd @PSBoundParameters }
            $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
            $steppablePipeline.Begin($PSCmdlet)
        }
        catch {
            throw
        }
    }

    process {
        try {
            $steppablePipeline.Process($_)
            Save-CdCommandHistory # Build up the DB.
        }
        catch {
            throw
        }
    }

    end {
        try {
            $steppablePipeline.End()
        }
        catch {
            throw
        }
    }
}

function popdX {
    [CmdletBinding(SupportsTransactions = $true, HelpUri = 'http://go.microsoft.com/fwlink/?LinkID=113369')]
    param(
        [switch]
        ${PassThru},

        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]
        ${StackName})

    begin {
        try {
            $outBuffer = $null
            if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer)) {
                $PSBoundParameters['OutBuffer'] = 1
            }
            $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Microsoft.PowerShell.Management\Pop-Location', [System.Management.Automation.CommandTypes]::Cmdlet)
            $scriptCmd = { & $wrappedCmd @PSBoundParameters }
            $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
            $steppablePipeline.Begin($PSCmdlet)
        }
        catch {
            throw
        }
    }

    process {
        try {
            $steppablePipeline.Process($_)
        }
        catch {
            throw
        }
    }

    end {
        try {
            $steppablePipeline.End()
        }
        catch {
            throw
        }
    }
    <#
    .ForwardHelpTargetName Microsoft.PowerShell.Management\Pop-Location
    .ForwardHelpCategory Cmdlet
    #>

}

<#
.SYNOPSIS
Get the locations in the location stacks used by z, cd, pushd.
 
.DESCRIPTION
To store location history, this module provided wrapper for aliases commonly used for directory navigation: cd, pushd, popd.
This module also uses Push-Location under the hood for all navigation commands (z, cd, pushd) to store the current
navigation history (in the default unamed location stack).
Due to the way Powershell work, all the location stacks used by commands in this module is not accessible to outside commands.
(This may be due to them being on different *runspace*. See: https://go.microsoft.com/fwlink/?LinkID=113321#notes)
This command allow you access the location used by commands from this module.
 
.PARAMETER StackName
Specifies, as a string array, the named location stacks. Enter one or more location stack names.
If omitted, show the default unamed stack.
 
.EXAMPLE
Get the locations in location stack a and b
> zz -StackName a, b
#>

function zz {
    [CmdletBinding(SupportsTransactions = $true, HelpUri = 'https://go.microsoft.com/fwlink/?LinkID=113321')]
    param(
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string[]]
        ${StackName}
        )

    begin {
        try {
            $outBuffer = $null
            if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer)) {
                $PSBoundParameters['OutBuffer'] = 1
            }

            $PSBoundParameters['Stack'] = $true
            $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Microsoft.PowerShell.Management\Get-Location', [System.Management.Automation.CommandTypes]::Cmdlet)
            $scriptCmd = { & $wrappedCmd @PSBoundParameters }
            $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
            $steppablePipeline.Begin($PSCmdlet)
        }
        catch {
            throw
        }
    }

    process {
        try {
            $steppablePipeline.Process($_)
        }
        catch {
            throw
        }
    }

    end {
        try {
            $steppablePipeline.End()
        }
        catch {
            throw
        }
    }
}

# A wrapper function around the existing Set-Location Cmdlet.
function cdX {
    [CmdletBinding(DefaultParameterSetName = 'Path', SupportsTransactions = $true, HelpUri = 'http://go.microsoft.com/fwlink/?LinkID=113397')]
    param(
        [Parameter(ParameterSetName = 'Path', Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        ${Path},

        [Parameter(ParameterSetName = 'LiteralPath', Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('PSPath')]
        [string]
        ${LiteralPath},

        [switch]
        ${PassThru},

        [Parameter(ParameterSetName = 'Stack', ValueFromPipelineByPropertyName = $true)]
        [string]
        ${StackName})

    begin {
        $outBuffer = $null
        if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer)) {
            $PSBoundParameters['OutBuffer'] = 1
        }

        $PSBoundParameters['ErrorAction'] = 'Stop'

        $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Set-Location', [System.Management.Automation.CommandTypes]::Cmdlet)
        $scriptCmd = { & $wrappedCmd @PSBoundParameters }

        $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
        $steppablePipeline.Begin($PSCmdlet)
    }

    process {
        $steppablePipeline.Process($_)

        Save-CdCommandHistory # Build up the DB.
    }

    end {
        $steppablePipeline.End()
    }
}

function Get-DirectoryEntryMatchPredicate {
    Param(
        [Parameter(
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true)]
        $Path,

        [Parameter(
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true)]
        [string] $JumpPath,

        $ProviderRegex
    )

    if ($null -ne $Path) {

        $null = .{
            $providerMatches = [System.Text.RegularExpressions.Regex]::Match($Path.FullName, $ProviderRegex).Success
        }

        if ($providerMatches) {
            
            # Allows matching of entire names. Remove the first two characters, added by PowerShell when the user presses the TAB key.
            if ($JumpPath.StartsWith('.\')) {
                $JumpPath = $JumpPath.Substring(2).TrimEnd('\')
            }

            [System.Text.RegularExpressions.Regex]::Match($Path.Name, [System.Text.RegularExpressions.Regex]::Escape($JumpPath), [System.Text.RegularExpressions.RegexOptions]::IgnoreCase).Success
        }
    }
}

function Get-CurrentSessionProviderDrives([System.Collections.ArrayList] $ProviderDrives) {

    if ($IsLinux -Or $IsMacOS) {
        # Always only '/' which needs escaped to work in a regex
        '\/'
    }
    elseif ($ProviderDrives -ne $null -and $ProviderDrives.Length -gt 0) {
        Get-ProviderDrivesRegex $ProviderDrives
    }
    else {

        # The FileSystemProvider supports \\ and X:\ paths.
        # An ideal solution would be to ask the provider if a path is supported.
        # Supports drives such as C:\ and also UNC \\
        if ((Get-Location).Provider.ImplementingType.Name -eq 'FileSystemProvider') {
            '(?i)^(((' + [String]::Concat( ((Get-Location).Provider.Drives.Name | ForEach-Object { $_ + '|' }) ).TrimEnd('|') + '):\\)|(\\{1,2})).*?'
        }
        else {
            Get-ProviderDrivesRegex (Get-Location).Provider.Drives
        }
    }
}

function Get-ProviderDrivesRegex([System.Collections.ArrayList] $ProviderDrives) {
    
    # UNC paths get special treatment. Allows one to 'z foo -ProviderDrives \\' and specify '\\' as the drive.
    if ($ProviderDrives -contains '\\') {
        $ProviderDrives.('\\')
    }

    if ($ProviderDrives.Count -eq 0) {
        '(?i)^(\\{1,2}).*?'
    }
    else {
        $uncRootPathRegex = '|(\\{1,2})'
        '(?i)^((' + [String]::Concat( ($ProviderDrives | ForEach-Object { $_ + '|' }) ).TrimEnd('|') + '):\\)' + $uncRootPathRegex + '.*?'
    }
}

function Get-Frecency($rank, $time) {

    # Last access date/time
    $dx = (Get-Date).Subtract((New-Object System.DateTime -ArgumentList $time)).TotalSeconds

    if ( $dx -lt 3600 ) { return $rank * 4 }

    if ( $dx -lt 86400 ) { return $rank * 2 }

    if ( $dx -lt 604800 ) { return $rank / 2 }

    return $rank / 4
}

function Cleanup-CdCommandHistory() {

    try {

        for ($i = 0; $i -lt $global:history.Length; $i++) {

            $line = $global:history[$i]

            if ($null -ne $line) {
                $testDir = $line.Path.FullName
                if (-not [string]::IsNullOrWhiteSpace($testDir) -and !(Test-Path $testDir)) {
                    $global:history[$i] = $null
                    Write-Host "Removed inaccessible path $testDir" -ForegroundColor Yellow
                }
            }
        }
        Remove-Old-History
        WriteHistoryToDisk
    }
    catch {
        Write-Host $_.Exception.ToString() -ForegroundColor Red
    }
}


function Remove-Old-History() {
    if ($global:history.Length -gt 1000) {
        $global:history | Where-Object { $_ -ne $null } | ForEach-Object { $i = 0 } {

            $lineObj = $_
            $lineObj.Rank = $lineObj.Rank * 0.99

            # If it's been accessed in the last 14 days it can stay
            # or
            # If it's rank is greater than 20 and been accessed in the last 30 days it can stay
            if ($lineObj.Age -lt 1209600 -or ($lineObj.Rank -ge 5 -and $lineObj.Age -lt 2592000)) {
                #$global:history[$i] = ConvertTo-DirectoryEntry (ConvertTo-TextualHistoryEntry $lineObj.Rank $lineObj.Path.FullName $lineObj.Time)
            }
            else {
                Write-Host "Removing old item: Rank:" $lineObj.Rank "Age:" ($lineObj.Age / 60 / 60) "Path:" $lineObj.Path.FullName -ForegroundColor Yellow
                $global:history[$i] = $null
            }
            $i++;
        }
    }
}


function Save-CdCommandHistory($removeCurrentDirectory = $false) {

    $currentDirectory = Get-FormattedLocation

    try {

        $foundDirectory = $false
        $runningTotal = 0

        for ($i = 0; $i -lt $global:history.Length; $i++) {

            $line = $global:history[$i]

            $canIncreaseRank = $true;

            $rank = $line.Rank;

            if (-not $foundDirectory) {

                $rank = $line.Rank

                if ($line.Path.FullName -eq $currentDirectory) {

                    $foundDirectory = $true

                    if ($removeCurrentDirectory) {
                        $canIncreaseRank = $false
                        $global:history[$i] = $null
                        Write-Host "Removed entry $currentDirectory" -ForegroundColor Green

                    }
                    else {
                        $rank++
                        Update-HistoryEntryUsageTime $global:history[$i]
                    }
                }
            }

            if ($canIncreaseRank) {
                $runningTotal += $rank
            }
        }

        if (-not $foundDirectory -and $removeCurrentDirectory) {
            Write-Host "Current directory not found in CD history data file" -ForegroundColor Red
        }
        else {

            if (-not $foundDirectory) {
                Save-HistoryEntry 1 $currentDirectory
                $runningTotal += 1
            }
            Remove-Old-History
        }

        WriteHistoryToDisk

    }
    catch {
        Write-Host $_.Exception.ToString() -ForegroundColor Red
    }
}

function WriteHistoryToDisk() {
    $newList = GetAllHistoryAsText $global:history
    Set-Content -Value $newList -Path $cdHistory -Encoding UTF8
}

function GetAllHistoryAsText($history) {
    return $history | Where-Object { $_ -ne $null } | ForEach-Object { ConvertTo-TextualHistoryEntry $_.Rank $_.Path.FullName $_.Time }
}

function Get-FormattedLocation() {
    if ((Get-Location).Provider.ImplementingType.Name -eq 'FileSystemProvider' -and (Get-Location).Path.Contains('FileSystem::\\')) {
        Get-Location | Select-Object -ExpandProperty ProviderPath # The registry provider does return a path which z understands. In other words, I'm too lazy.
    }
    else {
        Get-Location | Select-Object -ExpandProperty Path
    }
}

function Format-Rank($rank) {
    return $rank.ToString("000#.00", [System.Globalization.CultureInfo]::InvariantCulture);
}

function Save-HistoryEntry($rank, $directory) {
    $entry = ConvertTo-TextualHistoryEntry $rank $directory
    $global:history += ConvertTo-DirectoryEntry $entry
}

function Update-HistoryEntryUsageTime($historyEntry) {
    $historyEntry.Rank++
    $historyEntry.Time = (Get-Date).Ticks
}

function ConvertTo-TextualHistoryEntry($rank, $directory, $lastAccessedTicks) {
    if ($null -eq $lastAccessedTicks) {
        $lastAccessedTicks = (Get-Date).Ticks
    }

    (Format-Rank $rank) + $lastAccessedTicks + $directory
}

function ConvertTo-DirectoryEntry {
    Param(
        [Parameter(
            Position = 0,
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true)]
        [String]$line
    )

    Process {

        $null = .{

            $pathValue = $line.Substring(25)

            try {
                $fileName = [System.IO.Path]::GetFileName($pathValue);
                
                # GetFileName() does not work with registry paths
                if ($fileName -eq '') {
                    $lastPathSeparator = $pathValue.LastIndexOf('\');
                    if ($lastPathSeparator -ge 0) {
                        $pathValue = $pathValue.TrimEnd('\');
                        $fileName = $pathValue.Substring( + 1);
                    }
                }
            }
            catch [System.ArgumentException] { }

            $time = [long]::Parse($line.Substring(7, 18), [Globalization.CultureInfo]::InvariantCulture)
        }

        @{
            Rank = GetRankFromLine $line;
            Time = $time;
            Path = @{ Name = $fileName; FullName = $pathValue };
            Age  = (Get-Date).Subtract((New-Object System.DateTime -ArgumentList $time)).TotalSeconds;
        }
    }
}

function GetRankFromLine([String]$line) {
    $null = .{ $rankStr = $line.Substring(0, 7) }
    [double]::Parse($rankStr, [Globalization.CultureInfo]::InvariantCulture)
}

function Get-MostRecentDirectoryEntries {

    $mruEntries = (Get-Item -Path HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\TypedPaths | ForEach-Object { $item = $_; $_.GetValueNames() | ForEach-Object { $item.GetValue($_) } })

    $mruEntries | ForEach-Object { ConvertTo-TextualHistoryEntry 1 $_ }
}

function Get-ArgsFilter {
    Param(
        [Parameter(ValueFromPipeline = $true)]
        [Hashtable]$historyEntry,

        [string]
        $Option = 'Frecency'
    )

    Process {

        if ($Option -in ('Frecency', 'F')) {
            $_['Score'] = (Get-Frecency $_.Rank $_.Time);
        }
        elseif ($Option -in ('Time', 'T')) {
            $_['Score'] = $_.Time;
        }
        elseif ($Option -in ('Rank', 'R')) {
            $_['Score'] = $_.Rank;
        }

        return $_;
    }
}

<#
 
.ForwardHelpTargetName Set-Location
.ForwardHelpCategory Cmdlet
 
#>


# Get cdHistory and hydrate a in-memory collection
$global:history = @()
if ((Test-Path -Path $cdHistory)) {
    $global:history += Get-Content -Path $cdHistory -Encoding UTF8 | Where-Object { (-not [String]::IsNullOrWhiteSpace($_)) } | ConvertTo-DirectoryEntry
}

# $orig_cd = (Get-Alias -Name 'cd').Definition
# $orig_pushd = (Get-Alias -Name 'pushd').Definition
# $orig_popd = (Get-Alias -Name 'popd').Definition
# $MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
# set-item alias:cd -value $orig_cd
# set-item alias:pushd -value $orig_pushd
# set-item alias:popd -value $orig_popd
# }

#Override the existing CD command with the wrapper in order to log 'cd' commands.
# Set-item alias:cd -Value 'pushdX'
# Set-item alias:pushd -Value 'pushdX'
# Set-item alias:popd -Value 'popdX'

Set-Alias -Name pushd -Value pushdX -Force -Option AllScope -Scope Global
Set-Alias -Name cd -Value pushdX -Force -Option AllScope -Scope Global
Set-Alias -Name popd -Value popdX -Force -Option AllScope -Scope Global

Export-ModuleMember -Function z, cdX, pushdX, popdX, zz -Alias cd, pushd, popd

# Tab Completion
$completion_RunningService = {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)

    $global:history | Sort-Object { $_.Rank } -Descending | Where-Object { $_.Path.Name -like "*$wordToComplete*" } |
    ForEach-Object { New-Object System.Management.Automation.CompletionResult ("'{0}'" -f $_.Path.FullName), $_.Path.FullName, 'ParameterName', ('{0} ({1})' -f $_.Path.Name, $_.Path.FullName) }
}

if (-not $global:options) { $global:options = @{CustomArgumentCompleters = @{}; NativeArgumentCompleters = @{} } }

$global:options['CustomArgumentCompleters']['z:JumpPath'] = $Completion_RunningService

$function:tabexpansion2 = $function:tabexpansion2 -replace 'End(\r\n|\n){', 'End { if ($null -ne $options) { $options += $global:options} else {$options = $global:options}'