z.psm1
$cdHistory = Join-Path -Path $Env:USERPROFILE -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 regular expression of the directory name to jump to. .PARAMETER Option Frecency - Match by frecency (default) Rank - Match by rank only Time - Match by recent access only List - List only CurrentDirectory - Restrict matches to subdirectories of the current directory .PARAMETER $ProviderDrives A comma separated string of drives to match on. If none is specified, it will use a drive list from the currently selected provider. For example, the following command will run the regular expression 'foo' against all folder names where the drive letters in your history match HKLM:\ C:\ or D:\ z foo -p HKLM,C,D .PARAMETER $Remove Remove the current directory from the datafile .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/vincpa/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", "List", "L", "CurrentDirectory", "C")] [Alias('o')] [string] $Option = 'Frecency', [Alias('p')] $ProviderDrives = $null, [Alias('x')] [switch] $Remove = $null) if ((-not $Remove) -and [string]::IsNullOrWhiteSpace($JumpPath)) { Get-Help z; return; } if ((Test-Path $cdHistory)) { if ($Remove) { Save-CdCommandHistory $Remove } else { # This causes conflicts with the -Remove parameter. Not sure whether to remove registry entry. #$mruList = Get-MostRecentDirectoryEntries $providerRegex = $null if ($ProviderDrives.Length -gt 0) { $providerRegex = Get-CurrentSessionProviderDrives $ProviderDrives } else { $providerRegex = Get-CurrentSessionProviderDrives ((Get-PSProvider).Drives | select -ExpandProperty Name) } $list = @() $global:history | ? { Get-DirectoryEntryMatchPredicate -path $_.Path -jumpPath $JumpPath -ProviderRegex $providerRegex } | Get-ArgsFilter -Option $Option | % { $list += $_ } if ($Option -ne $null -and $Option.Length -gt 0 -and $Option[0] -eq 'l') { $newList = $list | % { New-Object PSObject -Property @{Rank = $_.Rank; Path = $_.Path.FullName; LastAccessed = [DateTime]$_.Time } } Format-Table -InputObject $newList -AutoSize } else { if ($list.Length -eq 0) { Write-Host "$JumpPath Not found" } else { if ($list.Length -gt 1) { $entry = $list | Sort-Object -Descending { $_.Score } | select -First 1 } else { $entry = $list[0] } 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 #> } # 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( Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] [string] $JumpPath, [string] $ProviderRegex ) if ($Path -ne $null) { $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('\') } $JumpPath = [System.Text.RegularExpressions.Regex]::Escape($JumpPath) [System.Text.RegularExpressions.Regex]::Match($Path.Name, $JumpPath, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase).Success } } } function Get-CurrentSessionProviderDrives([System.Collections.ArrayList] $ProviderDrives) { if ($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 | % { $_ + '|' }) ).TrimEnd('|') + '):\\)|(\\{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)^(\\{2}).*?' } else { $uncRootPathRegex = '|(\\{2})' '(?i)^((' + [String]::Concat( ($ProviderDrives | % { $_ + '|' }) ).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 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 } if ($runningTotal -gt 1000) { $global:history | ? { $_ -ne $null } | % {$i = 0} { $lineObj = $_ $lineObj.Rank = $lineObj.Rank * 0.99 Write-Host "Rank:" $lineObj.Rank "Age:" ($lineObj.Age/60/60) "Path:" $lineObj.Path.FullName # 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 { $global:history[$i] = $null } $i++; } } } WriteHistoryToDisk } catch { Write-Host $_.Exception.ToString() -ForegroundColor Red } } function WriteHistoryToDisk() { $newList = GetAllHistoryAsText $global:history Out-File -InputObject $newList -FilePath "$cdHistory.tmp" Remove-Item $cdHistory Rename-Item -Path "$cdHistory.tmp" -NewName $cdHistory } function GetAllHistoryAsText($history) { return $history | ? { $_ -ne $null } | % { 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 -ExpandProperty ProviderPath # The registry provider does return a path which z understands. In other words, I'm too lazy. } else { Get-Location | select -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 ($lastAccessedTicks -eq $null) { $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); } 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 | % { $item = $_; $_.GetValueNames() | % { $item.GetValue($_) } }) $mruEntries | % { ConvertTo-TextualHistoryEntry 1 $_ } } function Get-ArgsFilter { Param( [Parameter(ValueFromPipeline=$true)] [Hashtable]$historyEntry, [string] $Option = 'Frecency' ) Process { if ($Option -eq 'Frecency') { $_['Score'] = (Get-Frecency $_.Rank $_.Time); } elseif ($Option -eq 'Time') { $_['Score'] = $_.Time; } elseif ($Option -eq 'Rank') { $_['Score'] = $_.Rank; } return $_; } } <# .ForwardHelpTargetName Set-Location .ForwardHelpCategory Cmdlet #> # Get cdHistory and hydrate a in-memory collection $global:history = @() $global:history += Get-Content -Path $cdHistory -Encoding UTF8 | ? { (-not [String]::IsNullOrWhiteSpace($_)) } | ConvertTo-DirectoryEntry $orig_cd = (Get-Alias -Name 'cd').Definition $MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { set-item alias:cd -value $orig_cd } #Override the existing CD command with the wrapper in order to log 'cd' commands. Set-item alias:cd -Value 'cdX' Set-Alias -Name pushd -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 -Alias cd, pushd |