GoSaveTool.psm1

# GoSaveTool: SRT and WebVTT subtitle tools, word and character counts, social media limits and case changes.
# The PowerShell version of @gosavetool/subtitles and @gosavetool/text. https://www.gosavetool.com/
Set-StrictMode -Version 3

#region Subtitles

$script:TimeRx = '(\d{1,2}:)?(\d{1,2}):(\d{1,2})[,.](\d{1,3})'
$script:ArrowRx = '^\s*((?:\d{1,2}:)?\d{1,2}:\d{1,2}[,.]\d{1,3})\s*-->\s*((?:\d{1,2}:)?\d{1,2}:\d{1,2}[,.]\d{1,3})(.*)$'

function ConvertTo-Milliseconds([string] $Stamp) {
    $m = [regex]::Match($Stamp, $script:TimeRx)
    if (-not $m.Success) { return $null }
    $h = if ($m.Groups[1].Success) { [int]$m.Groups[1].Value.TrimEnd(':') } else { 0 }
    return (([int64]$h * 60 + [int]$m.Groups[2].Value) * 60 + [int]$m.Groups[3].Value) * 1000 + [int]$m.Groups[4].Value.PadRight(3, '0')
}

function ConvertFrom-Milliseconds([double] $Ms, [string] $Separator = ',') {
    # Half up, as JavaScript's Math.round
    $ms = [int64][math]::Max(0, [math]::Floor($Ms + 0.5))
    return '{0:00}:{1:00}:{2:00}{3}{4:000}' -f [math]::Floor($ms / 3600000), ([math]::Floor($ms / 60000) % 60), ([math]::Floor($ms / 1000) % 60), $Separator, ($ms % 1000)
}

function Read-SubtitleInput($Path, $Text) {
    if ($Path) { return [IO.File]::ReadAllText((Resolve-Path -LiteralPath $Path).ProviderPath, [Text.Encoding]::UTF8) }
    return [string]$Text
}

function Test-WebVtt([string] $Text) {
    return $Text.TrimStart([char]0xFEFF).StartsWith('WEBVTT', [StringComparison]::Ordinal)
}

<#
.SYNOPSIS
Reads the cues of an SRT or WebVTT file or text.
.DESCRIPTION
Returns one object per cue with Start and End in milliseconds, Settings (WebVTT cue settings) and Text.
Headers, NOTE and STYLE blocks are skipped. Online editor: https://www.gosavetool.com/vtt-subtitle-generator
.EXAMPLE
ConvertFrom-Subtitle -Path movie.srt | Select-Object -First 3
.EXAMPLE
Get-Content movie.vtt -Raw | ConvertFrom-Subtitle
#>

function ConvertFrom-Subtitle {
    [CmdletBinding(DefaultParameterSetName = 'Text')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Path', Position = 0)] [string] $Path,
        [Parameter(Mandatory, ParameterSetName = 'Text', ValueFromPipeline)] [AllowEmptyString()] [string] $Text
    )
    process {
        $src = Read-SubtitleInput $Path $Text
        # "\r\r\n" too: a file saved twice with Windows line endings
        $norm = [regex]::Replace($src.TrimStart([char]0xFEFF), '\r*\n|\r', "`n")
        foreach ($block in [regex]::Split($norm, '\n{2,}')) {
            $lines = $block -split "`n"
            for ($i = 0; $i -lt $lines.Count; $i++) {
                $m = [regex]::Match($lines[$i], $script:ArrowRx)
                if ($m.Success) {
                    $body = if ($i + 1 -lt $lines.Count) { ($lines[($i + 1)..($lines.Count - 1)] -join "`n").Trim() } else { '' }
                    [PSCustomObject]@{
                        PSTypeName = 'GoSaveTool.Cue'
                        Start      = ConvertTo-Milliseconds $m.Groups[1].Value
                        End        = ConvertTo-Milliseconds $m.Groups[2].Value
                        Settings   = $m.Groups[3].Value.Trim()
                        Text       = $body
                    }
                    break
                }
            }
        }
    }
}

<#
.SYNOPSIS
Writes cues as SRT text, numbered from 1.
.EXAMPLE
ConvertFrom-Subtitle -Path movie.vtt | ConvertTo-Srt | Set-Content movie.srt -Encoding UTF8
#>

function ConvertTo-Srt {
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory, ValueFromPipeline)] [AllowEmptyCollection()] [object[]] $Cue)
    begin { $all = [Collections.Generic.List[object]]::new() }
    process { foreach ($c in $Cue) { $all.Add($c) } }
    end {
        $i = 0
        (($all | ForEach-Object { $i++; "$i`n$(ConvertFrom-Milliseconds $_.Start ',') --> $(ConvertFrom-Milliseconds $_.End ',')`n$($_.Text)" }) -join "`n`n") + "`n"
    }
}

<#
.SYNOPSIS
Writes cues as WebVTT text; cue settings are kept.
.EXAMPLE
ConvertFrom-Subtitle -Path movie.srt | ConvertTo-WebVtt | Set-Content movie.vtt -Encoding UTF8
#>

function ConvertTo-WebVtt {
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory, ValueFromPipeline)] [AllowEmptyCollection()] [object[]] $Cue)
    begin { $all = [Collections.Generic.List[object]]::new() }
    process { foreach ($c in $Cue) { $all.Add($c) } }
    end {
        $body = ($all | ForEach-Object {
                $settings = if ($_.Settings) { ' ' + $_.Settings } else { '' }
                "$(ConvertFrom-Milliseconds $_.Start '.') --> $(ConvertFrom-Milliseconds $_.End '.')$settings`n$($_.Text)"
            }) -join "`n`n"
        "WEBVTT`n`n" + $body + "`n"
    }
}

function Write-SubtitleOutput([string] $Content, [string] $Target, [switch] $PassThru) {
    if ($PassThru) { return $Content }
    # UTF-8 without a byte order mark, as players expect
    [IO.File]::WriteAllText($Target, $Content, [Text.UTF8Encoding]::new($false))
    Get-Item -LiteralPath $Target
}

<#
.SYNOPSIS
Converts an SRT file to WebVTT or a WebVTT file to SRT.
.DESCRIPTION
Writes name.vtt or name.srt next to the file, or to -OutFile. With -PassThru the text is returned instead.
.EXAMPLE
Convert-Subtitle movie.srt -To vtt
.EXAMPLE
Get-ChildItem *.srt | Convert-Subtitle -To vtt
#>

function Convert-Subtitle {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] [Alias('FullName')] [string] $Path,
        [Parameter(Mandatory)] [ValidateSet('srt', 'vtt')] [string] $To,
        [string] $OutFile,
        [switch] $PassThru
    )
    process {
        $cues = @(ConvertFrom-Subtitle -Path $Path)
        if (-not $cues.Count) { Write-Error "No subtitle cues found in $Path"; return }
        $out = if ($To -eq 'vtt') { ConvertTo-WebVtt -Cue $cues } else { ConvertTo-Srt -Cue $cues }
        $full = (Resolve-Path -LiteralPath $Path).ProviderPath
        $target = if ($OutFile) { $OutFile } else { [IO.Path]::ChangeExtension($full, $To) }
        if ($PassThru -or $PSCmdlet.ShouldProcess($target, 'Write subtitles')) { Write-SubtitleOutput $out $target -PassThru:$PassThru }
    }
}

<#
.SYNOPSIS
Moves every cue of a subtitle file earlier or later.
.DESCRIPTION
Positive seconds move cues later, negative earlier. Cues pushed before zero are clipped or dropped.
Changes the file in place unless -OutFile or -PassThru is given.
.EXAMPLE
Move-SubtitleTiming movie.srt -Seconds -1.5
#>

function Move-SubtitleTiming {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] [Alias('FullName')] [string] $Path,
        [Parameter(Mandatory, Position = 1)] [double] $Seconds,
        [string] $OutFile,
        [switch] $PassThru
    )
    process {
        $src = Read-SubtitleInput $Path $null
        $ms = [int64][math]::Floor($Seconds * 1000 + 0.5)
        $cues = @(ConvertFrom-Subtitle -Text $src | ForEach-Object {
                $end = [math]::Max(0, $_.End + $ms)
                if ($end -gt 0) { [PSCustomObject]@{ PSTypeName = 'GoSaveTool.Cue'; Start = [math]::Max(0, $_.Start + $ms); End = $end; Settings = $_.Settings; Text = $_.Text } }
            })
        $out = if (Test-WebVtt $src) { ConvertTo-WebVtt -Cue $cues } else { ConvertTo-Srt -Cue $cues }
        $target = if ($OutFile) { $OutFile } else { (Resolve-Path -LiteralPath $Path).ProviderPath }
        if ($PassThru -or $PSCmdlet.ShouldProcess($target, "Move cues by $Seconds s")) { Write-SubtitleOutput $out $target -PassThru:$PassThru }
    }
}

<#
.SYNOPSIS
Repairs a subtitle file: sorts cues, fixes overlaps and end times before the start, drops empty cues, renumbers.
.DESCRIPTION
Changes the file in place unless -OutFile or -PassThru is given, and writes the number of problems fixed to the information stream.
.EXAMPLE
Repair-Subtitle movie.srt -InformationAction Continue
#>

function Repair-Subtitle {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] [Alias('FullName')] [string] $Path,
        [int] $MinDuration = 2000,
        [string] $OutFile,
        [switch] $PassThru
    )
    process {
        $src = Read-SubtitleInput $Path $null
        $r = Repair-Cue -Cue @(ConvertFrom-Subtitle -Text $src) -MinDuration $MinDuration
        $out = if (Test-WebVtt $src) { ConvertTo-WebVtt -Cue $r.Cues } else { ConvertTo-Srt -Cue $r.Cues }
        Write-Information ($(if ($r.Fixed) { "$($r.Fixed) problems fixed" } else { 'No problems found' }))
        $target = if ($OutFile) { $OutFile } else { (Resolve-Path -LiteralPath $Path).ProviderPath }
        if ($PassThru -or $PSCmdlet.ShouldProcess($target, 'Repair subtitles')) { Write-SubtitleOutput $out $target -PassThru:$PassThru }
    }
}

<#
.SYNOPSIS
Repairs cue objects; returns an object with Cues and Fixed (the number of changes).
#>

function Repair-Cue {
    [CmdletBinding()]
    param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Cue, [int] $MinDuration = 2000)
    $fixed = @($Cue | Where-Object { $_.Text -eq '' }).Count
    $i = 0
    $out = @($Cue | Where-Object { $_.Text -ne '' } | ForEach-Object {
            [PSCustomObject]@{ PSTypeName = 'GoSaveTool.Cue'; Start = $_.Start; End = $_.End; Settings = $_.Settings; Text = $_.Text; Index = $i++ } })
    $before = @($out | ForEach-Object { $_.Start })
    # Stable: equal starts keep their order
    $out = @($out | Sort-Object Start, Index)
    for ($k = 0; $k -lt $out.Count; $k++) { if ($out[$k].Start -ne $before[$k]) { $fixed++; break } }
    for ($k = 0; $k -lt $out.Count; $k++) {
        $c = $out[$k]
        if (-not ($c.End -gt $c.Start)) { $c.End = $c.Start + $MinDuration; $fixed++ }
        if ($k + 1 -lt $out.Count -and $c.End -gt $out[$k + 1].Start) { $c.End = [math]::Max($c.Start + 1, $out[$k + 1].Start - 1); $fixed++ }
        $c.PSObject.Properties.Remove('Index')
    }
    [PSCustomObject]@{ Cues = $out; Fixed = $fixed }
}

<#
.SYNOPSIS
Format, number of cues and end time of a subtitle file.
.EXAMPLE
Get-ChildItem *.srt | Get-SubtitleInfo
#>

function Get-SubtitleInfo {
    [CmdletBinding()]
    param([Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] [Alias('FullName')] [string] $Path)
    process {
        $src = Read-SubtitleInput $Path $null
        $cues = @(ConvertFrom-Subtitle -Text $src)
        $vtt = Test-WebVtt $src
        $end = if ($cues.Count) { ($cues | Measure-Object End -Maximum).Maximum } else { 0 }
        [PSCustomObject]@{
            Path   = $Path
            Format = if ($vtt) { 'WebVTT' } else { 'SRT' }
            Cues   = $cues.Count
            EndsAt = ConvertFrom-Milliseconds $end $(if ($vtt) { '.' } else { ',' })
        }
    }
}

#endregion

#region Text

$script:Limits = [ordered]@{
    'x-post'                = 280
    'instagram-caption'     = 2200
    'tiktok-caption'        = 4000
    'youtube-title'         = 100
    'youtube-description'   = 5000
    'linkedin-post'         = 3000
    'pinterest-description' = 500
    'seo-title'             = 60
    'meta-description'      = 160
}
$script:Small = @('a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'in', 'nor', 'of', 'on', 'or', 'so', 'the', 'to', 'up', 'yet', 'via', 'vs')

# Code points, so an emoji counts as one character as in JavaScript's [...s].length
function Get-CodePointCount([string] $s) {
    $n = 0
    foreach ($ch in $s.ToCharArray()) { if (-not [char]::IsLowSurrogate($ch)) { $n++ } }
    return $n
}

<#
.SYNOPSIS
Words, characters, sentences, lines and reading time of a text.
.DESCRIPTION
Reading time uses 238 words a minute, the average silent reading speed for English non-fiction.
The same counter is online at https://www.gosavetool.com/character-counter
.EXAMPLE
Get-Content post.txt -Raw | Get-TextStats
#>

function Get-TextStats {
    [CmdletBinding()]
    param([Parameter(Mandatory, ValueFromPipeline, Position = 0)] [AllowEmptyString()] [string] $Text)
    process {
        $words = [regex]::Matches($Text, "[\p{L}\p{N}][\p{L}\p{N}'’_-]*").Count
        [PSCustomObject]@{
            Words         = $words
            Chars         = Get-CodePointCount $Text
            CharsNoSpaces = Get-CodePointCount ([regex]::Replace($Text, '\s', ''))
            Lines         = if ($Text -eq '') { 0 } else { [regex]::Split($Text, '\r\n|\r|\n').Count }
            Sentences     = [regex]::Matches($Text, '[^.!?…]+[.!?…]+').Count
            ReadSeconds   = [int][math]::Floor($words / 238 * 60 + 0.5)
        }
    }
}

<#
.SYNOPSIS
How a text meets the character limits of X, Instagram, TikTok, YouTube, LinkedIn, Pinterest and SEO fields.
.EXAMPLE
'My new video is live!' | Test-TextLimit | Format-Table
.EXAMPLE
Test-TextLimit -Text $caption -Field instagram-caption, tiktok-caption
#>

function Test-TextLimit {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [AllowEmptyString()] [string] $Text,
        [ValidateSet('x-post', 'instagram-caption', 'tiktok-caption', 'youtube-title', 'youtube-description', 'linkedin-post', 'pinterest-description', 'seo-title', 'meta-description')]
        [string[]] $Field
    )
    process {
        $used = Get-CodePointCount $Text
        foreach ($k in $script:Limits.Keys) {
            if ($Field -and $Field -notcontains $k) { continue }
            [PSCustomObject]@{ Field = $k; Limit = $script:Limits[$k]; Used = $used; Left = $script:Limits[$k] - $used; Fits = $used -le $script:Limits[$k] }
        }
    }
}

function Split-Words([string] $s) {
    $spaced = [regex]::Replace($s, '([\p{Ll}\p{N}])(\p{Lu})', '$1 $2')
    return @([regex]::Split($spaced, '[^\p{L}\p{N}]+') | Where-Object { $_ })
}

function Get-TitleCase([string] $s) {
    $state = @{ First = $true }
    return [regex]::Replace($s.ToLowerInvariant(), "[\p{L}\p{N}][\p{L}\p{N}'’]*", {
            param($m)
            $afterColon = [regex]::IsMatch($s.Substring(0, $m.Index), '[:.!?]\s*$')
            $keep = -not $state.First -and -not $afterColon -and $script:Small -ccontains $m.Value
            $state.First = $false
            if ($keep) { $m.Value } else { $m.Value.Substring(0, 1).ToUpperInvariant() + $m.Value.Substring(1) }
        })
}

function Get-SentenceCase([string] $s) {
    return [regex]::Replace($s.ToLowerInvariant(), '(^\s*|[.!?…]\s+)(\p{L})', { param($m) $m.Groups[1].Value + $m.Groups[2].Value.ToUpperInvariant() })
}

<#
.SYNOPSIS
URL slug of a text, with Turkish and accented letters: 'Ölçü Birimi & Çeviri!' becomes 'olcu-birimi-ceviri'.
.EXAMPLE
'Best Posting Time on Instagram' | ConvertTo-Slug
#>

function ConvertTo-Slug {
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory, ValueFromPipeline, Position = 0)] [AllowEmptyString()] [string] $Text)
    begin {
        $map = @{ 0x131 = 'i'; 0x130 = 'i'; 0x15F = 's'; 0x15E = 's'; 0x11F = 'g'; 0x11E = 'g'; 0xE7 = 'c'; 0xC7 = 'c'; 0xF6 = 'o'; 0xD6 = 'o'
            0xFC = 'u'; 0xDC = 'u'; 0xDF = 'ss'; 0xE6 = 'ae'; 0xF8 = 'o'; 0x111 = 'd'; 0x142 = 'l' }
    }
    process {
        $sb = [Text.StringBuilder]::new()
        foreach ($ch in $Text.ToCharArray()) { if ($map.ContainsKey([int]$ch)) { [void]$sb.Append($map[[int]$ch]) } else { [void]$sb.Append($ch) } }
        $plain = [regex]::Replace($sb.ToString().Normalize([Text.NormalizationForm]::FormKD), '[̀-ͯ]', '').ToLowerInvariant()
        [regex]::Replace([regex]::Replace($plain, '[^a-z0-9]+', '-'), '^-+|-+$', '')
    }
}

<#
.SYNOPSIS
Changes the case of a text: Title, Sentence, Upper, Lower, Slug, Camel, Snake or Kebab.
.DESCRIPTION
Title Case keeps short words (a, of, the, and…) in lower case unless they start the text or follow a colon.
.EXAMPLE
'the lord of the rings: return of the king' | Convert-TextCase -To Title
.EXAMPLE
Convert-TextCase 'best posting time' -To Camel
#>

function Convert-TextCase {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [AllowEmptyString()] [string] $Text,
        [Parameter(Mandatory)] [ValidateSet('Title', 'Sentence', 'Upper', 'Lower', 'Slug', 'Camel', 'Snake', 'Kebab')] [string] $To
    )
    process {
        switch ($To) {
            'Title' { Get-TitleCase $Text }
            'Sentence' { Get-SentenceCase $Text }
            'Upper' { $Text.ToUpperInvariant() }
            'Lower' { $Text.ToLowerInvariant() }
            'Slug' { ConvertTo-Slug $Text }
            'Camel' {
                $w = @(Split-Words $Text)
                $parts = for ($i = 0; $i -lt $w.Count; $i++) {
                    if ($i -eq 0) { $w[$i].ToLowerInvariant() } else { $w[$i].Substring(0, 1).ToUpperInvariant() + $w[$i].Substring(1).ToLowerInvariant() }
                }
                -join $parts
            }
            'Snake' { (Split-Words $Text | ForEach-Object { $_.ToLowerInvariant() }) -join '_' }
            'Kebab' { (Split-Words $Text | ForEach-Object { $_.ToLowerInvariant() }) -join '-' }
        }
    }
}

<#
.SYNOPSIS
Removes trailing spaces, runs of spaces and more than one blank line in a row.
.EXAMPLE
Get-Content draft.txt -Raw | Format-TextWhitespace | Set-Content draft.txt
#>

function Format-TextWhitespace {
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory, ValueFromPipeline, Position = 0)] [AllowEmptyString()] [string] $Text)
    process {
        $nl = if ($Text.Contains("`r`n")) { "`r`n" } else { "`n" }
        $lines = [regex]::Split($Text, '\r\n|\n') | ForEach-Object { [regex]::Replace([regex]::Replace($_, '[ \t]+$', ''), '(\S)[ \t]{2,}', '$1 ') }
        [regex]::Replace(($lines -join $nl), '(' + [regex]::Escape($nl) + '){3,}', $nl + $nl)
    }
}

#endregion

Export-ModuleMember -Function ConvertFrom-Subtitle, ConvertTo-Srt, ConvertTo-WebVtt, Convert-Subtitle, Move-SubtitleTiming, Repair-Subtitle, Get-SubtitleInfo,
    Get-TextStats, Test-TextLimit, Convert-TextCase, ConvertTo-Slug, Format-TextWhitespace