MetaNull.WeeklyCalendarDocx.psm1

# Module Constants

# Set-Variable MYMODULE_CONSTANT -option Constant -value $true
Function Add-TableRow {
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    $Table,

    [Parameter(Mandatory)]
    [int]$RowNumber,

    [Parameter(Mandatory)]
    [AllowEmptyString()]
    [string]$Text,

    [string]$FontFamily,

    [int]$FontSize = 8,

    [double]$LineHeight = 1.0,

    [switch]$Bold,

    [System.Drawing.Color]$FontColor,

    [switch]$HasNoBorders
)

$Constants = Get-WordConstants -All

try {
    # Get the cell and clear it
    $cell = $Table.Cell($RowNumber, 1)
    $cell.Range.Text = "" # Required to avoid Word reusing the previous range!
    Set-CellBorders -Cell $cell -LineStyle $Constants.WD_LINE_STYLE_NONE

    # Set the Line Height
    $row = $Table.Rows.Item($RowNumber)
    $row.HeightRule = $Constants.WD_ROW_HEIGHT_EXACTLY
    $row.Height = $Table.Application.CentimetersToPoints($LineHeight)

    # Configure the paragraph
    $paragraph = $cell.Range.Paragraphs.Item(1)
    $paragraph.Range.Text = $Text
    $paragraph.Range.Font.Size = $FontSize

    if ($FontFamily) {
        $paragraph.Range.Font.Name = $FontFamily
    }

    if ($Bold) {
        $paragraph.Range.Font.Bold = $true
    }

    if ($FontColor) {
        $paragraph.Range.Font.Color = $FontColor
    }

    # Apply borders unless HasNoBorders is specified
    if (-not $HasNoBorders) {
        Set-ParagraphBorders -Paragraph $paragraph
    }
}
catch {
    Write-Warning "Failed to add table row $RowNumber with text '$Text': $_"
}
}
Function Get-LanguageConfiguration {
[CmdletBinding()]
param(
    [Parameter(ParameterSetName = 'GetLanguage')]
    [string]$Language,

    [Parameter(ParameterSetName = 'ListLanguages')]
    [switch]$ListAvailable
)

$LanguageConfig = @{
    French = @{
        Days = @("LUN", "MAR", "MER", "JEU", "VEN")
        WeekPrefix = "SEM."
        DateRangeFormat = "({0} → {1})"
        DateFormat = @{
            SameMonth = @{
                From = "%d"
                To = "d'/'MM"
            }
            DifferentMonth = @{
                From = "d'/'MM"
                To = "d'/'MM'/'yyyy"
            }
            DifferentYear = @{
                From = "d'/'MM'/'yyyy"
                To = "d'/'MM'/'yyyy"
            }
        }
    }
    English = @{
        Days = @("MON", "TUE", "WED", "THU", "FRI")
        WeekPrefix = "WK. "
        DateFormat = @{
            SameMonth = @{
                From = "d"
                To = "d'/'MM"
            }
            DifferentMonth = @{
                From = "d'/'MM"
                To = "d'/'MM'/'yyyy"
            }
            DifferentYear = @{
                From = "d'/'MM'/'yyyy"
                To = "d'/'MM'/'yyyy"
            }
        }
    }
}

if ($ListAvailable) {
    return $LanguageConfig.Keys | Sort-Object
}

if ($Language -and $LanguageConfig.ContainsKey($Language)) {
    return $LanguageConfig[$Language]
} elseif ($Language) {
    throw "Language '$Language' is not supported. Available languages: $($LanguageConfig.Keys -join ', ')"
} else {
    throw "Language parameter is required when not listing available languages."
}
}
Function Set-CellBorders {
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    $Cell,

    [int]$LineStyle = (Get-WordConstants -ConstantName 'WD_LINE_STYLE_NONE')
)

try {
    $Cell.Borders.Item(1).LineStyle = $LineStyle
    $Cell.Borders.Item(2).LineStyle = $LineStyle
    $Cell.Borders.Item(3).LineStyle = $LineStyle
    $Cell.Borders.Item(4).LineStyle = $LineStyle
    $Cell.Borders.Item(5).LineStyle = $LineStyle
    $Cell.Borders.Item(6).LineStyle = $LineStyle
}
catch {
    Write-Warning "Failed to set cell borders: $_"
}
}
Function Set-ParagraphBorders {
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    $Paragraph,

    [int]$BottomLineStyle = (Get-WordConstants -ConstantName 'WD_LINE_STYLE_SINGLE'),
    [int]$LineWidth = (Get-WordConstants -ConstantName 'WD_LINE_WIDTH_075PT')
)

$Constants = Get-WordConstants -All

try {
    $Paragraph.Format.Borders.Enable = $true
    $Paragraph.Format.Borders.Item(1).LineStyle = $Constants.WD_LINE_STYLE_NONE  # Top
    $Paragraph.Format.Borders.Item(2).LineStyle = $Constants.WD_LINE_STYLE_NONE  # Left
    $Paragraph.Format.Borders.Item(4).LineStyle = $Constants.WD_LINE_STYLE_NONE  # Right
    $Paragraph.Format.Borders.Item(3).LineStyle = $BottomLineStyle     # Bottom
    $Paragraph.Format.Borders.Item(3).LineWidth = $LineWidth
}
catch {
    Write-Warning "Failed to set paragraph borders: $_"
}
}
Function Test-FileOverwrite {
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$FilePath,

    [switch]$Force
)

if (Test-Path $FilePath) {
    if (-not $Force) {
        $response = Read-Host "File '$FilePath' already exists. Overwrite? (Y/N)"
        if ($response -notmatch '^[Yy]') {
            throw "Operation cancelled by user."
        }
    }

    # Test if file is locked
    try {
        [System.IO.File]::OpenWrite($FilePath).Close()
    }
    catch {
        throw "File '$FilePath' is locked or cannot be overwritten: $_"
    }
}
}
Function Test-WordApplication {
[CmdletBinding()]
param()

try {
    $testWord = New-Object -ComObject Word.Application -ErrorAction Stop
    $testWord.Quit()
    [System.Runtime.InteropServices.Marshal]::ReleaseComObject($testWord) | Out-Null
    return $true
}
catch {
    Write-Error "Microsoft Word is not available: $_"
    return $false
}
}
Function Get-ISOWeekNumber {
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [DateTime]$Date
)

try {
    $calendar = [System.Globalization.CultureInfo]::InvariantCulture.Calendar
    $week = $calendar.GetWeekOfYear(
        $Date,
        [System.Globalization.CalendarWeekRule]::FirstFourDayWeek,
        [System.DayOfWeek]::Monday
    )

    # If the week is 53, check if it belongs to next year
    if ($week -eq 53) {
        $nextYearJan1 = Get-Date -Year ($Date.Year + 1) -Month 1 -Day 1
        $nextWeek = $calendar.GetWeekOfYear(
            $nextYearJan1,
            [System.Globalization.CalendarWeekRule]::FirstFourDayWeek,
            [System.DayOfWeek]::Monday
        )

        if ($nextWeek -eq 1 -and $Date -ge $nextYearJan1.AddDays(-3)) {
            return 1
        }
    }
    return $week
}
catch {
    Write-Error "Failed to calculate ISO week number for date $($Date): $_"
    throw
}
}
Function Get-WeekStartDate {
[CmdletBinding(DefaultParameterSetName="ByYearWeek")]
param(
    [Parameter(Mandatory,ParameterSetName="ByYearWeek")]
    #[ValidateRange(1900, 2100)]
    [int]$Year,

    [Parameter(Mandatory,ParameterSetName="ByYearWeek")]
    [ValidateRange(1, 53)]
    [int]$Week,

    [Parameter(Mandatory,ParameterSetName="ByDate")]
    [ValidateNotNull()]
    [DateTime]$Date
)
try {
    if ($PSCmdlet.ParameterSetName -eq "ByDate") {
        # Calculate the Monday of the week containing $Date
        $dayOfWeek = ([int]$Date.DayOfWeek + 6) % 7
        return $Date.AddDays(-$dayOfWeek).Date
    } else {
        # Calculate the Monday of the specified ISO week and year
        # ISO weeks start with the week containing the first Thursday of the year
        $jan4 = Get-Date -Year $Year -Month 1 -Day 4
        $dayOfWeek = [int]$jan4.DayOfWeek
        $monday = $jan4.AddDays(-($dayOfWeek - 1))
        return $monday.AddDays(($Week - 1) * 7)
    }
}
catch {
    Write-Error "Failed to calculate week start date for Year $Year, Week $($Week): $_"
    throw
}
}
Function Get-WordConstants {
[CmdletBinding(DefaultParameterSetName='GetAll')]
param(
    [Parameter(ParameterSetName = 'GetConstant')]
    [string]$ConstantName,

    [Parameter(ParameterSetName = 'GetAll')]
    [switch]$All,

    [Parameter(ParameterSetName = 'ListConstants')]
    [switch]$ListAvailable
)

$WordConstants = @{
    WD_LINE_STYLE_NONE = 0
    WD_LINE_STYLE_SINGLE = 1
    WD_SECTION_BREAK_NEXT_PAGE = 2
    WD_LINE_WIDTH_075PT = 6
    WD_AUTOFIT_FIXED = 0
    WD_PREFERRED_WIDTH_PERCENT = 1
    # Additional commonly used Word constants
    WD_PAGE_BREAK = 1
    WD_LINE_STYLE_DOUBLE = 7
    WD_LINE_WIDTH_150PT = 12
    WD_LINE_WIDTH_225PT = 18
    WD_LINE_WIDTH_300PT = 24
    WD_BORDER_TOP = 1
    WD_BORDER_LEFT = 2
    WD_BORDER_BOTTOM = 3
    WD_BORDER_RIGHT = 4
    WD_BORDER_HORIZONTAL = 5
    WD_BORDER_VERTICAL = 6
    WD_ROW_HEIGHT_AUTO = 0
    WD_ROW_HEIGHT_AT_LEAST = 1
    WD_ROW_HEIGHT_EXACTLY = 2
}

switch ($PSCmdlet.ParameterSetName) {
    'GetConstant' {
        if ($WordConstants.ContainsKey($ConstantName)) {
            return $WordConstants[$ConstantName]
        } else {
            throw "Constant '$ConstantName' not found. Use -ListAvailable to see available constants."
        }
    }
    'GetAll' {
        return $WordConstants
    }
    'ListConstants' {
        return $WordConstants.Keys | Sort-Object
    }
    default {
        # Default behavior - return all constants
        return $WordConstants
    }
}
}
Function New-WeeklyCalendar {
<#
.SYNOPSIS
    Generates a weekly calendar document using Microsoft Word.
 
.DESCRIPTION
    Creates a formatted weekly calendar showing work days (Monday-Friday) for a specified number of weeks.
    The calendar displays ISO week numbers and provides space for daily planning.
 
.PARAMETER Year
    The year for which to generate the calendar. Must be between 1900 and 2100.
 
.PARAMETER FromWeek
    The starting ISO week number (1-53). Defaults to 1.
 
.PARAMETER NumberOfWeeks
    The number of weeks to generate (1-52). Defaults to 6.
 
.PARAMETER BreakAfter
    Number of weeks after which to insert a page break. Defaults to 3. Set to 0 to disable page breaks.
 
.PARAMETER SaveAs
    Path where to save the document. If not specified, Word will be opened visibly for interactive use.
 
.PARAMETER Language
    Language for day names and date formatting. Supported: French, English.
 
.PARAMETER FontSize
    Font size. Defaults to 10.
 
.PARAMETER FontFamily
    Font family to use throughout the document. Defaults to 'Aptos'.
 
.PARAMETER Force
    When saving, overwrite existing files without prompting.
 
.EXAMPLE
    New-WeeklyCalendar -Year 2025 -FromWeek 10 -NumberOfWeeks 8
    Creates a calendar for weeks 10-17 of 2025 and opens it in Word.
 
.EXAMPLE
    New-WeeklyCalendar -SaveAs "C:\temp\calendar.docx" -Year 2025 -Force
    Creates a full year calendar for 2025 and saves it to the specified path.
 
.EXAMPLE
    New-WeeklyCalendar -Language English -FontSize 16
    Creates a calendar with English language settings and larger font.
 
.NOTES
    Requires Microsoft Word to be installed.
    Uses ISO 8601 week numbering (weeks start on Monday).
 
    Author: Pascal Havelange
    License: MIT License - https://opensource.org/licenses/MIT
             You are free to use, modify, and distribute this software without restriction.
#>

[CmdletBinding()]
param(
    [Parameter(ParameterSetName = 'Default')]
    [Parameter(ParameterSetName = 'SaveAs')]
    #[ValidateRange(1900, 2100)]
    [int]$Year = (Get-Date).Year,

    [Parameter(ParameterSetName = 'Default')]
    [Parameter(ParameterSetName = 'SaveAs')]
    #[ValidateRange(1, 53)]
    [int]$FromWeek = 1,

    [Parameter(ParameterSetName = 'Default')]
    [Parameter(ParameterSetName = 'SaveAs')]
    #[ValidateRange(1, 52)]
    [int]$NumberOfWeeks = 6,

    [Parameter(ParameterSetName = 'Default')]
    [Parameter(ParameterSetName = 'SaveAs')]
    [ValidateRange(0, 53)]
    [int]$BreakAfter = 3,

    [Parameter(ParameterSetName = 'SaveAs', Mandatory = $true)]
    [ValidateScript({
        $directory = Split-Path $_ -Parent
        if (-not (Test-Path $directory -PathType Container)) {
            throw "Directory '$directory' does not exist."
        }
        $true
    })]
    [string]$SaveAs,

    [Parameter(ParameterSetName = 'Default')]
    [Parameter(ParameterSetName = 'SaveAs')]
    [ValidateScript({
        $availableLanguages = Get-LanguageConfiguration -ListAvailable
        if ($_ -in $availableLanguages) {
            $true
        } else {
            throw "Language '$_' is not supported. Available languages: $($availableLanguages -join ', ')"
        }
    })]
    [string]$Language = 'French',

    [Parameter(ParameterSetName = 'Default')]
    [Parameter(ParameterSetName = 'SaveAs')]
    [ValidateRange(8, 24)]
    [int]$FontSize = 10,

    [Parameter(ParameterSetName = 'Default')]
    [Parameter(ParameterSetName = 'SaveAs')]
    [string]$FontFamily = 'Aptos',

    [Parameter(ParameterSetName = 'SaveAs')]
    [switch]$Force
)

$ParameterSetName = $PSCmdlet.ParameterSetName
$Constants = Get-WordConstants -All
$LanguageConfig = Get-LanguageConfiguration -Language $Language

# Validate Word application availability
if (-not (Test-WordApplication)) {
    throw "Microsoft Word is required but not available."
}

# Test file overwrite if saving
if ($ParameterSetName -eq 'SaveAs') {
    Test-FileOverwrite -FilePath $SaveAs -Force:$Force
}

# Initialize progress tracking
$progress = @{
    Activity = "Generating Weekly Calendar"
    Status = "Initializing..."
    PercentComplete = 0
}

Write-Progress @progress

# Create Word application with timeout handling
$word = $null
$doc = $null

try {
    $word = New-Object -ComObject Word.Application
    if ($ParameterSetName -eq 'SaveAs') {
        $word.Visible = $false
    } else {
        $word.Visible = $true
    }

    # Create a new document
    $doc = $word.Documents.Add()
    $doc.PageSetup.TopMargin = $word.CentimetersToPoints(2.0)
    $doc.PageSetup.BottomMargin = $word.CentimetersToPoints(1.5)
    $doc.PageSetup.LeftMargin = $word.CentimetersToPoints(0.5)
    $doc.PageSetup.RightMargin = $word.CentimetersToPoints(0.5)

    $groupingCounter = 0
    $totalWeeks = $NumberOfWeeks + 1

    for ($week = $FromWeek; $week -lT ($FromWeek + $NumberOfWeeks); $week++) {
        $groupingCounter++
        $currentProgress = [math]::Round(($groupingCounter / $totalWeeks) * 100, 0)

        $startDate = Get-WeekStartDate -Year $Year -Week $week
        $endDate = $startDate.AddDays(4)
        $actualWeek = Get-ISOWeekNumber -Date $startDate

        $progress.Status = "Processing Year $($startDate.Year), week # $actualWeek ($groupingCounter of $totalWeeks)"
        $progress.PercentComplete = $currentProgress
        Write-Progress @progress

        if ($groupingCounter -ne 1 -and $week -gt $FromWeek -and $endDate.Year -gt $startDate.Year) {
            # Section break on new year - insert break at current position
            $doc.Range($doc.Content.End - 1, $doc.Content.End - 1).InsertBreak($Constants.WD_SECTION_BREAK_NEXT_PAGE)
            $groupingCounter = 1
        }

        # Add a table with 8 rows and 1 column
        $docParagraph = $doc.Paragraphs.Add()
        $docParagraph.Format.KeepTogether = $true
        $docParagraph.Format.KeepWithNext = $true

        $table = $doc.Tables.Add($docParagraph.Range, 8, 1)
        $table.AutoFitBehavior($Constants.WD_AUTOFIT_FIXED)
        $table.AllowAutoFit = $false
        $table.PreferredWidthType = $Constants.WD_PREFERRED_WIDTH_PERCENT
        $table.PreferredWidth = 100
        $table.Columns.Item(1).PreferredWidthType = $Constants.WD_PREFERRED_WIDTH_PERCENT
        $table.Columns.Item(1).PreferredWidth = 100

        # Row 1: Header
        $CurrentRow = 1

        if ($groupingCounter -eq 1) {
            # First week of the group - show ending year or starting year week number is 1
            if ($actualWeek -eq 1) {
                $HeaderText = "$($LanguageConfig.WeekPrefix)$actualWeek ($($endDate.Year))"
            } else {
                $HeaderText = "$($LanguageConfig.WeekPrefix)$actualWeek ($($startDate.Year))"
            }
        } elseif ($actualWeek -in @(52,53)) {
            # Year transition case for week 52/53
            $HeaderText = "$($LanguageConfig.WeekPrefix)$actualWeek ($($startDate.Year))"
        } elseif ($actualWeek -in @(1,2)) {
            # Year transition case for week 1/2
            $HeaderText = "$($LanguageConfig.WeekPrefix)$actualWeek ($($endDate.Year))"
        } else {
            $HeaderText = "$($LanguageConfig.WeekPrefix)$actualWeek"
        }

        Add-TableRow -Table $table -RowNumber $CurrentRow -Text $HeaderText -FontFamily $FontFamily -FontSize ($FontSize + [int]($FontSize * .2)) -Bold

        # Row 2: Subtitle
        $CurrentRow++
        if ($startDate.Month -ne $endDate.Month) {
            if ($startDate.Year -ne $endDate.Year) {
                $SubtitleText = $LanguageConfig.DateRangeFormat -f $startDate.ToString($LanguageConfig.DateFormat.DifferentYear.From), $endDate.ToString($LanguageConfig.DateFormat.DifferentYear.To)
            } else {
                $SubtitleText = $LanguageConfig.DateRangeFormat -f $startDate.ToString($LanguageConfig.DateFormat.DifferentMonth.From), $endDate.ToString($LanguageConfig.DateFormat.DifferentMonth.To)
            }
        } else {
            $SubtitleText = $LanguageConfig.DateRangeFormat -f $startDate.ToString($LanguageConfig.DateFormat.SameMonth.From), $endDate.ToString($LanguageConfig.DateFormat.SameMonth.To)
        }

        Add-TableRow -Table $table -RowNumber $CurrentRow -Text $SubtitleText -FontFamily $FontFamily -FontSize $FontSize -FontColor ([System.Drawing.Color]::FromArgb(128, 0, 0))

        # Row 3: Spacer
        $CurrentRow++
        Add-TableRow -Table $table -RowNumber $CurrentRow -Text "`t" -FontFamily $FontFamily -FontSize $FontSize

        # Rows 4 to 8: Days
        $days = $LanguageConfig.Days
        for ($i = 0; $i -lt $days.Count; $i++) {
            $CurrentRow++
            Add-TableRow -Table $table -RowNumber $CurrentRow -Text "$($days[$i])`t:" -FontFamily $FontFamily -FontSize $FontSize
        }

        if ($breakAfter -gt 0) {
            if ($groupingCounter -gt 0 -and $groupingCounter % $breakAfter -eq 0) {
                # Page break on every multiple of breakAfter (save for the very last week)
                if ($week -lt (($FromWeek + $NumberOfWeeks) - 1)) {
                    $doc.Range($doc.Content.End - 1, $doc.Content.End - 1).InsertBreak($Constants.WD_SECTION_BREAK_NEXT_PAGE)
                }
            } else {
                # Add controlled spacing after each table
                $newPara = $doc.Paragraphs.Add()
                $newPara.SpaceAfter = 0  # Remove after-paragraph spacing
                $newPara.SpaceBefore = 6  # Small before-paragraph spacing (6 points)
            }
        } else {
            # Add controlled spacing after each table
            $newPara = $doc.Paragraphs.Add()
            $newPara.SpaceAfter = 0  # Remove after-paragraph spacing
            $newPara.SpaceBefore = 6  # Small before-paragraph spacing (6 points)
        }
    }

    $progress.PercentComplete = 100
    $progress.Status = "Finalizing document..."
    Write-Progress @progress

    if ($ParameterSetName -eq 'SaveAs') {
        Write-Information "Saving Calendar to: $SaveAs"
        $doc.SaveAs([ref] $SaveAs)
        $doc.Close()
        $word.Quit()
    } else {
        Write-Information "Calendar created and opened in Word."
    }
}
finally {
    # Cleanup COM objects
    Write-Progress -Activity "Generating Weekly Calendar" -Completed

    if ($doc) {
        try {
            [System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
        }
        catch {
            Write-Warning "Failed to release document COM object: $_"
        }
    }

    if ($word) {
        try {
            if ($ParameterSetName -eq 'SaveAs') {
                $word.Quit()
            }
            [System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null
        }
        catch {
            Write-Warning "Failed to release Word COM object: $_"
        }
    }

    [System.GC]::Collect()
    [System.GC]::WaitForPendingFinalizers()
}
}