Functions/GenXdev.AI/GenerateMasonryLayoutHtml.ps1

<##############################################################################
Part of PowerShell module : GenXdev.AI
Original cmdlet filename : GenerateMasonryLayoutHtml.ps1
Original author : René Vaessen / GenXdev
Version : 1.274.2025
################################################################################
MIT License
 
Copyright 2021-2025 GenXdev
 
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
 
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
 
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
################################################################################>

###############################################################################
<#
.SYNOPSIS
Generates a responsive masonry layout HTML gallery from image data.
 
.DESCRIPTION
Creates an interactive HTML gallery with responsive masonry grid layout for
displaying images. Features include:
- Responsive grid layout that adapts to screen size
- Image tooltips showing descriptions and keywords
- Click-to-copy image path functionality
- Clean modern styling with hover effects
 
.PARAMETER Images
Array of image objects containing metadata. Each object requires:
- path: String with full filesystem path to image
- keywords: String array of descriptive tags
- description: Object containing short_description and long_description
 
.PARAMETER FilePath
Optional output path for the HTML file. If omitted, returns HTML as string.
 
.EXAMPLE
Create gallery from image array and save to file
$images = @(
    @{
        path = "C:\photos\sunset.jpg"
        keywords = @("nature", "sunset", "landscape")
        description = @{
            short_description = "Mountain sunset"
            long_description = "Beautiful sunset over mountain range"
        }
    }
)
GenerateMasonryLayoutHtml -Images $images -FilePath "C:\output\gallery.html"
 
.EXAMPLE
Generate HTML string without saving
$html = GenerateMasonryLayoutHtml $images
#>

function GenerateMasonryLayoutHtml {

    [CmdletBinding()]
    [OutputType([System.String])]
    param (
        ###############################################################################
        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipeline = $true,
            HelpMessage = 'Array of image objects with path, keywords and description'
        )]
        [System.Collections.Generic.IEnumerable[GenXdev.Helpers.ImageSearchResult]] $Images,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            Position = 1,
            HelpMessage = 'Output path for the generated HTML file'
        )]
        [string]$FilePath = $null,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Title for the gallery'
        )]
        [string]$Title = 'Photo Gallery',
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Description for the gallery'
        )]
        [string]$Description = 'Hover over images to see face recognition, object detection, and scene classification data',
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Whether editing is enabled'
        )]
        [Switch]$CanEdit = $false,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Whether deletion is enabled'
        )]
        [Switch]$CanDelete = $false,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Embed images as base64 data URLs instead of file:// URLs for better portability'
        )]
        [Switch]$EmbedImages = $false,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Show only pictures in a rounded rectangle, no text below.'
        )]
        [Alias('NoMetadata', 'OnlyPictures')]
        [switch] $ShowOnlyPictures,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Auto-scroll the page by this many pixels per second (null to disable)'
        )]
        [int]$AutoScrollPixelsPerSecond = $null,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Animate rectangles (objects/faces) in visible range, cycling every 300ms'
        )]
        [switch]$AutoAnimateRectangles,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Force single column layout (centered, 1/3 screen width)'
        )]
        [switch]$SingleColumnMode = $false,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Prefix to prepend to each image path (e.g. for remote URLs)'
        )]
        [string]$ImageUrlPrefix = '',
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Number of images to load per page (for dynamic loading)'
        )]
        [int]$PageSize = 20,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Maximum number of images to load for print mode'
        )]
        [int]$MaxPrintImages = 50,
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'IntersectionObserver rootMargin for infinite scroll trigger (e.g. "1200px")'
        )]
        [string]$RootMargin = '1200px',
        ###############################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'IntersectionObserver threshold for infinite scroll trigger'
        )]
        [double]$Threshold = 0.1
    )

    begin {
        $templatePath = "$PSScriptRoot\masonary.html"

        # Load System.Web for HTML encoding
        Microsoft.PowerShell.Utility\Add-Type -AssemblyName System.Web

        Microsoft.PowerShell.Utility\Write-Verbose "Starting HTML generation for $($Images.Count) images using template: $templatePath"

        # Verify template file exists
        if (-not (Microsoft.PowerShell.Management\Test-Path -LiteralPath $templatePath)) {

            throw "Template file not found: $templatePath"
        }

        # Helper function to convert image to base64 data URL
        function ConvertTo-Base64DataUrl {
            param(
                [Parameter(Mandatory = $true)]
                [string]$ImagePath
            )

            try {
                # Check if file exists
                if (-not (Microsoft.PowerShell.Management\Test-Path -LiteralPath $ImagePath)) {

                    Microsoft.PowerShell.Utility\Write-Verbose "Image file not found: $ImagePath"
                    return $null
                }

                # Determine MIME type based on file extension
                $extension = [System.IO.Path]::GetExtension($ImagePath).ToLower()
                $mimeType = switch ($extension) {
                    '.jpg'  { 'image/jpeg' }
                    '.gif'  { 'image/gif' }
                    '.jpeg' { 'image/jpeg' }
                    '.png'  { 'image/png' }
                    '.bmp'   { 'image/bmp' }
                    '.webp'  { 'image/webp' }
                    '.tiff'  { 'image/tiff' }
                    '.tif'   { 'image/tiff' }
                    default {
                        Microsoft.PowerShell.Utility\Write-Verbose "Unsupported image format: $extension"
                        return $null
                    }
                }

                # Read image file and convert to base64
                $imageBytes = [System.IO.File]::ReadAllBytes($ImagePath)
                $base64String = [System.Convert]::ToBase64String($imageBytes)

                # Create data URL
                $dataUrl = "data:$mimeType;base64,$base64String"

                Microsoft.PowerShell.Utility\Write-Verbose "Converted image to base64 data URL: $ImagePath ($(($imageBytes.Length / 1KB).ToString('F1')) KB)"

                return $dataUrl
            }
            catch {
                Microsoft.PowerShell.Utility\Write-Verbose "Failed to convert image to base64: $ImagePath - $_"
                return $null
            }
        }
    }

    process {
        # Read the HTML template
        Microsoft.PowerShell.Utility\Write-Verbose "Reading HTML template from: $templatePath"
        $html = Microsoft.PowerShell.Management\Get-Content -LiteralPath $templatePath -Raw -Encoding UTF8

        # Convert image paths for browser compatibility
        if ($EmbedImages) {
            Microsoft.PowerShell.Utility\Write-Verbose 'Converting image paths to base64 data URLs'
        } else {
            Microsoft.PowerShell.Utility\Write-Verbose 'Converting image paths to file:// URLs'
        }

        [System.Collections.Generic.List[GenXdev.Helpers.ImageSearchResult]] $processedImages = @()
        foreach ($image in $Images) {
            $imageCopy = $image.PSObject.Copy()
            if ($imageCopy.path) {
                # Store original path for copy functionality
                $imageCopy | Microsoft.PowerShell.Utility\Add-Member -MemberType NoteProperty -Name 'originalPath' -Value $imageCopy.path -Force

                # If ImageUrlPrefix is provided, always use it + filename (with forward slashes)
                if ($ImageUrlPrefix) {
                    $prefix = $ImageUrlPrefix
                    if ($prefix[-1] -ne '/') { $prefix += '/' }
                    $filename = [System.IO.Path]::GetFileName($imageCopy.path)
                    $imageCopy.path = ($prefix + $filename) -replace '\\', '/'
                }
                # else, just normalize slashes
                else {
                    $imageCopy.path = $imageCopy.path -replace '\\', '/'
                }

                if ($EmbedImages) {
                    # Convert to base64 data URL for embedded display
                    $dataUrl = ConvertTo-Base64DataUrl -ImagePath $imageCopy.path
                    if ($null -ne $dataUrl) {
                        $imageCopy.path = $dataUrl
                    }
                }
            }
            $processedImages.Add($imageCopy)
        }

        # Convert images array to JSON with proper escaping
        Microsoft.PowerShell.Utility\Write-Verbose "Converting $($processedImages.Count) images to JSON"

        $imagesJson = [GenXdev.Helpers.ImageSearchResultSerialize]::ToJson(($processedImages))

        if ([string]::IsNullOrWhiteSpace($imagesJson) -or $imagesJson.Substring(0, 1) -ne '[') {

            # If the JSON does not start with an array, wrap it in an array
            $imagesJson = "[$imagesJson]"
        }

        # Escape the JSON for JavaScript string literal
        $escapedJson = $imagesJson | Microsoft.PowerShell.Utility\ConvertTo-Json -Compress
        # Replace the placeholder with actual image data
        Microsoft.PowerShell.Utility\Write-Verbose "Replacing placeholder JSON.parse(`"[]`") with actual image data"
        $html = "$html".Replace('images: JSON.parse("[]")', "images: JSON.parse($escapedJson)")

        # Replace other template variables if they exist
        if (-not [String]::IsNullOrWhiteSpace($Title)) {
            $escapedTitle = $Title | Microsoft.PowerShell.Utility\ConvertTo-Json
            # Replace JS property (mydata.title)
            $html = $html -replace '(title\s*:\s*)"[^"]*"', "`$1$escapedTitle"
            # Replace <title> tag
            $html = $html -replace '(<title>)(.*?)(</title>)', "`$1$Title`$3"
            Microsoft.PowerShell.Utility\Write-Verbose "Updated title to: $Title"
        }
        if (-not [String]::IsNullOrWhiteSpace($Description)) {
            $escapedDescription = $Description | Microsoft.PowerShell.Utility\ConvertTo-Json
            # Replace JS property (mydata.description)
            $html = $html -replace '(description\s*:\s*)"[^"]*"', "`$1$escapedDescription"
            # Replace meta description
            $html = $html -replace '(<meta name="description" content=")(.*?)(")', "`$1$Description`$3"
            Microsoft.PowerShell.Utility\Write-Verbose "Updated description to: $Description"
        }
        if ($CanEdit) {
            $html = "$html".Replace('canEdit: false', 'canEdit: true')
            Microsoft.PowerShell.Utility\Write-Verbose "Updated canEdit to: $CanEdit"
        }
        if ($CanDelete) {
            $html = "$html".Replace('canDelete: false', 'canDelete: true')
            Microsoft.PowerShell.Utility\Write-Verbose "Updated canDelete to: $CanDelete"
        }
        if ($ShowOnlyPictures) {
            $html = "$html".Replace('showOnlyPictures: false,', 'showOnlyPictures: true,')
            Microsoft.PowerShell.Utility\Write-Verbose "Updated showOnlyPictures to: $ShowOnlyPictures"
        }

        # Inject new mydata properties for dynamic loading
        $html = "$html".Replace('pageSize: 20', "pageSize: $PageSize")
        $html = "$html".Replace('maxPrintImages: 50', "maxPrintImages: $MaxPrintImages")
        $html = "$html".Replace('rootMargin: "1200px"', "rootMargin: `"$RootMargin`"")
        $html = "$html".Replace('threshold: 0.1', "threshold: $Threshold")

        # Inject new mydata properties
        if ($null -ne $AutoScrollPixelsPerSecond) {
            $autoScrollValue = if ($null -ne $AutoScrollPixelsPerSecond) { $AutoScrollPixelsPerSecond } else { 'null' }
            $html = "$html".replace('AutoScrollPixelsPerSecond: null', "AutoScrollPixelsPerSecond: $autoScrollValue")
        }
        if ($AutoAnimateRectangles) {
            $autoAnimateValue = if ($AutoAnimateRectangles) { 'true' } else { 'false' }
            $html = "$html".replace('AutoAnimateRectangles: false', "AutoAnimateRectangles: $autoAnimateValue")
        }
        # Inject SingleColumnMode property
        $singleColumnValue = if ($SingleColumnMode) { 'true' } else { 'false' }
        $html = "$html".replace('SingleColumnMode: false', "SingleColumnMode: $singleColumnValue")
    }

    end {
        # Either return HTML string or save to file based on parameters
        if ([string]::IsNullOrWhiteSpace($FilePath)) {
            Microsoft.PowerShell.Utility\Write-Verbose 'Returning HTML as string output'
            return $html
        }
        else {
            Microsoft.PowerShell.Utility\Write-Verbose "Saving HTML gallery to: $FilePath"
            $html | Microsoft.PowerShell.Utility\Out-File  (GenXdev.FileSystem\Expand-Path $FilePath -CreateDirectory) -Encoding utf8
        }
    }
}