param( [Parameter(Mandatory = $true)] [string[]]$RootPath, [Parameter(Mandatory = $true)] [string]$OutputHtml, [string]$BubbleStopwordsFile, [string[]]$ExcludeDirectories = @( ".git", "node_modules", "bin", "obj" ), [switch]$OpenAfterCreate ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Encode-HtmlDisplay { param([string]$Text) if ($null -eq $Text) { return "" } $encoded = $Text $encoded = $encoded.Replace("&", "&") $encoded = $encoded.Replace("<", "<") $encoded = $encoded.Replace(">", ">") $encoded = $encoded.Replace('"', """) return $encoded } function Convert-ToBase64Json { param( [Parameter(Mandatory = $true)] [object]$Value ) $json = $Value | ConvertTo-Json -Compress -Depth 100 $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) return [System.Convert]::ToBase64String($bytes) } function Normalize-RootPaths { param([string[]]$InputPaths) $normalized = New-Object System.Collections.Generic.List[string] foreach ($rp in $InputPaths) { if ([string]::IsNullOrWhiteSpace($rp)) { continue } $parts = @($rp) if ($rp -match ',') { $parts = $rp -split '\s*,\s*' } foreach ($part in $parts) { if ([string]::IsNullOrWhiteSpace($part)) { continue } $clean = $part.Trim() $clean = $clean.Trim('"', "'") if ($clean.StartsWith('@(')) { $clean = $clean.Substring(2) } if ($clean.StartsWith('(')) { $clean = $clean.Substring(1) } if ($clean.EndsWith(')')) { $clean = $clean.Substring(0, $clean.Length - 1) } $clean = $clean.Trim().Trim(',') if (-not [string]::IsNullOrWhiteSpace($clean)) { $normalized.Add($clean) } } } return @($normalized) } function Get-NormalizedPathForLogic { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return "" } if ($Path.StartsWith("\\")) { return $Path.TrimEnd('\') } return [System.IO.Path]::GetFullPath($Path).TrimEnd('\') } function Get-RelativePathSafe { param( [string]$BasePath, [string]$TargetPath ) $baseFull = Get-NormalizedPathForLogic -Path $BasePath $targetFull = Get-NormalizedPathForLogic -Path $TargetPath if ([string]::IsNullOrWhiteSpace($baseFull) -or [string]::IsNullOrWhiteSpace($targetFull)) { return "" } if ($baseFull -ieq $targetFull) { return "." } if ($baseFull.StartsWith("\\") -and $targetFull.StartsWith("\\")) { $baseTrim = $baseFull.TrimStart('\') $targetTrim = $targetFull.TrimStart('\') $baseParts = $baseTrim -split '\\' $targetParts = $targetTrim -split '\\' $maxCommon = [Math]::Min($baseParts.Count, $targetParts.Count) $commonCount = 0 for ($i = 0; $i -lt $maxCommon; $i++) { if ($baseParts[$i] -ieq $targetParts[$i]) { $commonCount++ } else { break } } if ($commonCount -eq $baseParts.Count) { if ($commonCount -ge $targetParts.Count) { return "." } $restParts = @() for ($j = $commonCount; $j -lt $targetParts.Count; $j++) { $restParts += $targetParts[$j] } if ($restParts.Count -eq 0) { return "." } return ($restParts -join '\') } return $targetFull } $baseWithSlash = $baseFull if (-not $baseWithSlash.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { $baseWithSlash += [System.IO.Path]::DirectorySeparatorChar } $baseUri = [System.Uri]::new($baseWithSlash) $targetUri = [System.Uri]::new($targetFull) $relativeUri = $baseUri.MakeRelativeUri($targetUri) $relativePath = [System.Uri]::UnescapeDataString($relativeUri.ToString()) return $relativePath -replace '/', '\' } function Convert-ToFileUri { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return "" } if ($Path.StartsWith("\\")) { $trimmed = $Path.TrimStart('\') $parts = $trimmed -split '\\' if ($parts.Count -ge 2) { $server = $parts[0] $segments = New-Object System.Collections.Generic.List[string] for ($i = 1; $i -lt $parts.Count; $i++) { $segments.Add([System.Uri]::EscapeDataString($parts[$i])) } return "file://$server/" + ($segments -join '/') } return "" } $full = [System.IO.Path]::GetFullPath($Path) if ($full -match '^[A-Za-z]:\\') { $normalized = $full -replace '\\', '/' if ($normalized -match '^([A-Za-z]):/(.*)$') { $drive = $matches[1] $rest = $matches[2] $segments = New-Object System.Collections.Generic.List[string] foreach ($segment in ($rest -split '/')) { if ($segment -ne "") { $segments.Add([System.Uri]::EscapeDataString($segment)) } } return "file:///$drive`:/" + ($segments -join '/') } } if ($full.StartsWith("/")) { $segments = New-Object System.Collections.Generic.List[string] foreach ($segment in ($full -split '/')) { if ($segment -ne "") { $segments.Add([System.Uri]::EscapeDataString($segment)) } } return "file:///" + ($segments -join '/') } return [System.Uri]::new($full).AbsoluteUri } function Get-PathTerms { param( [string]$RelativePath, [string]$FolderName ) $terms = New-Object System.Collections.Generic.List[string] if (-not [string]::IsNullOrWhiteSpace($RelativePath) -and $RelativePath -ne ".") { $parts = $RelativePath -split '[\\/\s_\-\.]+' foreach ($part in $parts) { if (-not [string]::IsNullOrWhiteSpace($part)) { $terms.Add($part.ToLowerInvariant()) } } } if (-not [string]::IsNullOrWhiteSpace($FolderName)) { $nameParts = $FolderName -split '[\\/\s_\-\.]+' foreach ($part in $nameParts) { if (-not [string]::IsNullOrWhiteSpace($part)) { $terms.Add($part.ToLowerInvariant()) } } } return ($terms | Select-Object -Unique) } function Get-AllSearchTokens { param( [string]$RelativePath, [string]$FullPath, [string]$FolderName ) $all = New-Object System.Collections.Generic.List[string] foreach ($source in @($RelativePath, $FullPath, $FolderName)) { if (-not [string]::IsNullOrWhiteSpace($source)) { $parts = $source -split '[\\/\s_\-\.]+' foreach ($part in $parts) { if (-not [string]::IsNullOrWhiteSpace($part)) { $all.Add($part.ToLowerInvariant()) } } } } return ($all | Select-Object -Unique) } function Test-IsExcludedDirectory { param( [System.IO.DirectoryInfo]$Directory, [string[]]$ExcludedNames ) return $ExcludedNames -contains $Directory.Name } function Get-BubbleStopwords { param( [string]$StopwordsFile ) $candidates = New-Object System.Collections.Generic.List[string] if (-not [string]::IsNullOrWhiteSpace($StopwordsFile)) { $candidates.Add($StopwordsFile) } if (-not [string]::IsNullOrWhiteSpace($PSCommandPath)) { $scriptDir = Split-Path -Path $PSCommandPath -Parent $scriptBase = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath) $candidates.Add((Join-Path $scriptDir "$scriptBase.bubbles.conf.txt")) } foreach ($candidate in $candidates) { if (-not [string]::IsNullOrWhiteSpace($candidate) -and (Test-Path -LiteralPath $candidate)) { $words = Get-Content -LiteralPath $candidate -ErrorAction Stop | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') } | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique Write-Host "Verwendete Bubble-Stopwortdatei: $candidate" return @($words) } } Write-Host "Keine Bubble-Stopwortdatei gefunden." return @() } function Get-DirectoryTree { param( [System.IO.DirectoryInfo]$Directory, [string]$BasePath, [string[]]$ExcludedDirNames ) $relativePath = Get-RelativePathSafe -BasePath $BasePath -TargetPath $Directory.FullName if ([string]::IsNullOrWhiteSpace($relativePath)) { $relativePath = "." } $terms = Get-PathTerms -RelativePath $relativePath -FolderName $Directory.Name $linkUri = Convert-ToFileUri -Path $Directory.FullName $children = New-Object System.Collections.Generic.List[object] $subDirs = Get-ChildItem -LiteralPath $Directory.FullName -Directory -Force -ErrorAction SilentlyContinue | Where-Object { -not (Test-IsExcludedDirectory -Directory $_ -ExcludedNames $ExcludedDirNames) } | Sort-Object Name foreach ($subDir in $subDirs) { $children.Add((Get-DirectoryTree -Directory $subDir -BasePath $BasePath -ExcludedDirNames $ExcludedDirNames)) } return [PSCustomObject]@{ Name = $Directory.Name FullPath = $Directory.FullName RelativePath = $relativePath TermsArray = @($terms) SearchTokens = @(Get-AllSearchTokens -RelativePath $relativePath -FullPath $Directory.FullName -FolderName $Directory.Name) LinkUri = $linkUri LastWriteTime = $Directory.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss") LastWriteDate = $Directory.LastWriteTime.ToString("yyyy-MM-dd") LastWriteDateDisplay = $Directory.LastWriteTime.ToString("dd.MM.yyyy") Children = $children } } $RootPath = Normalize-RootPaths -InputPaths $RootPath $BubbleStopwords = Get-BubbleStopwords -StopwordsFile $BubbleStopwordsFile Write-Host "Verwendete RootPaths:" $RootPath | ForEach-Object { Write-Host " - $_" } $rootItems = @() foreach ($rp in $RootPath) { $resolved = $null try { $resolved = Get-Item -LiteralPath $rp -ErrorAction Stop } catch { throw "RootPath konnte nicht gelesen werden: $rp" } if (-not $resolved.PSIsContainer) { throw "RootPath muss ein Verzeichnis sein: $rp" } $rootItems += $resolved } $forest = foreach ($rootItem in $rootItems) { Get-DirectoryTree -Directory $rootItem -BasePath $rootItem.FullName -ExcludedDirNames $ExcludeDirectories } $forestB64 = Convert-ToBase64Json -Value $forest $stopwordsB64 = Convert-ToBase64Json -Value $BubbleStopwords $rootsDisplay = ($rootItems | ForEach-Object { $_.FullName }) -join " | " $rootsEscaped = Encode-HtmlDisplay $rootsDisplay $generatedAt = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") $html = @'