dconf.psm1



#region private

function ConvertTo-Dconf
{
    <#
        .DESCRIPTION
        Formats KeyInfo objects as Dconf settings.
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [Dconf.KeyInfo[]]$InputObject
    )

    end
    {
        if ($MyInvocation.ExpectingInput)
        {
            $InputObject = $input
        }

        $ByPath = $InputObject | Group-Object Path
        $ByPath |
            ForEach-Object {
                "[$($_.Name)]"
                $_.Group | ForEach-Object {
                    "$($_.Key)=$($_.Value)"
                }
                ""
            } |
            Write-Output
    }
}

function ConvertTo-PSObject
{
    <#
        .DESCRIPTION
        Creates KeyInfo objects representing Dconf settings.
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [AllowEmptyString()]
        [string[]]$InputObject
    )

    end
    {
        if ($MyInvocation.ExpectingInput)
        {
            $InputObject = $input
        }

        $InputObject = $InputObject | Out-String | ForEach-Object Trim

        $InputObject -split "\n" |
            Where-Object {-not [string]::IsNullOrWhiteSpace($_)} |
            ForEach-Object {
                if ($_ -match '^\[(?<Path>.*)\]\s*$')
                {
                    $Path = $Matches.Path
                }
                else
                {
                    $Key, $Value = $_ -split '=', 2
                    [Dconf.KeyInfo]::new($Path, $Key, $Value)
                }
            }
    }
}

function Get-DconfPath
{
    <#
        .DESCRIPTION
        Get all dconf paths that exist in the system.
    #>


    [CmdletBinding()]
    param
    (
        [switch]$Flush
    )

    if ($Flush -or -not $Script:DconfPaths)
    {
        $Dump = dconf dump /
        if (-not $?)
        {
            throw ($Dump | Out-String).Trim()
        }

        $KeyPaths = $Dump |
            Select-String '^\[(?<path>.*)\]$' |
            ForEach-Object Matches |
            ForEach-Object Groups |
            Where-Object Name -eq "path" |
            ForEach-Object Value

        # dconf dump omits paths that do not contain keys. For completions, we want those paths.
        # Assumption: dump output is ordered heirarchically
        $Last = ""
        $Script:DconfPaths = $KeyPaths | ForEach-Object {
            $Current = $_
            while ($Last -and $Current -notmatch "^$Last/.*")
            {
                $Last = $Last -replace '[^/]+?$' -replace '/$'
            }

            while ($Current -ne $Last)
            {
                $NextSegment = $Current -replace $Last -replace '^/' -replace '/.*'
                $Last = $Last, $NextSegment -join '/' -replace '^/'
                $Last
            }
        }
    }
    $Script:DconfPaths
}

function Resolve-DconfPath
{
    <#
        .DESCRIPTION
        Replaces relative paths with full paths in output from dconf dump.
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, Position = 0)]
        [Alias('Path')]
        [string]$BasePath,

        [Parameter(ValueFromPipeline, Mandatory, Position = 1)]
        [AllowEmptyString()]
        [string]$Text
    )

    process
    {
        $Lines = $Text -split '\r?\n'
        foreach ($Line in $Lines)
        {
            # This path is relative to the path passed to dconf dump
            if ($Line -match '^\[(?<Path>.+)\]\s*$')
            {
                $Path = $BasePath, $Matches.Path -join '/' -replace '/{2,}', '/' -replace '^/' -replace '/$'
                "[$Path]"
            }
            else
            {
                $Line
            }
        }
    }

    end {""}
}

function Set-Dconf
{
    <#
        .DESCRIPTION
        Imports dconf settings.

        .EXAMPLE
        dconf dump / > dump.txt
        Get-Content dump.txt | Set-Dconf
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Position = 0)]
        [string]$Path = '/',

        [Parameter(Mandatory, ValueFromPipeline, Position = 1)]
        [AllowEmptyString()]
        [string[]]$InputObject,

        [string[]]$Filter
    )

    end
    {
        $Path = $Path -replace '^/?', '/' -replace '(?<=[^/])$', '/'
        $_Path = $Path

        if ($Filter)
        {
            $Filter = @($Filter) -replace '^/?', '/' -replace '(?<=[^/])$', '/'
        }

        if ($MyInvocation.ExpectingInput)
        {
            $InputObject = $input
        }

        # Can't get past error: "Key file contains line [some_group] which is not a key-value pair, group, or comment"
        # So we use dconf write instead of dconf load
        $Lines = ($InputObject | Out-String).Trim() -split '\r?\n'
        foreach ($Line in $Lines)
        {
            if ([string]::IsNullOrWhiteSpace($Line) -or $Line.StartsWith('#'))
            {
                continue
            }
            elseif ($Line -match '^\[(?<Path>.+)\]\s*$')
            {
                $_Path = $(
                    $MatchedPath = $Matches.Path
                    if ($MatchedPath -eq '/')
                    {
                        $Path -replace '/$'
                    }
                    elseif ($MatchedPath -match '^/.')
                    {
                        $MatchedPath
                    }
                    else
                    {
                        $Path, $MatchedPath -join '/' -replace '/{2,}', '/'
                    }
                )

                $ShouldSkip = $Filter -and -not ($Filter | Where-Object {$_Path -ilike "$_*"})
                if ($ShouldSkip) {Write-Verbose "Skipping $_Path"}
                continue
            }

            if ($ShouldSkip) {continue}

            $Key, $Value = $Line -split '=', 2
            $FullKey = $_Path, $Key -join '/' -replace '/{2,}', '/'

            if ($Value -eq $Script:DCONF_RESET_SENTINEL)
            {
                dconf reset $FullKey
            }
            else
            {
                dconf write $FullKey "$Value"
            }

            if (-not $?)
            {
                Write-Error "Failed to write '$Value' to '$FullKey'"
            }
        }
    }
}

#endregion private

#region public

function Compare-Dconf
{
    <#
        .SYNOPSIS
        Compares two sets of dconf settings.

        .DESCRIPTION
        Shows differences between two sets of dconf settings.

        By default, this command captures a snapshot of all settings, then waits for you to make
        changes before capturing a second snapshot.

        .PARAMETER ReferenceObject
        The "before" settings.

        If this argument is not supplied, the command will dump the current settings as a base for
        comparison.

        .PARAMETER DifferenceObject
        The "after" settings.

        If this argument is not supplied, the command will pause while you make dconf changes,
        then capture the new settings for comparison.

        .EXAMPLE
        Compare-Dconf

        Captured dconf snapshot
        Waiting to capture snapshot (press enter after making changes):

        [org/gnome/desktop/app-folders]
        folder-children=['Utilities', 'YaST', 'Pardus']

        Captures the dconf changes made while the command was running. In this example, the user
        has modified the /org/gnome/desktop/app-folders/folder-children key.

        .EXAMPLE
        Export-Dconf > ./before

        <make dconf changes...>

        Export-Dconf > ./after

        Compare-Dconf (Get-Content ./before) (Get-Content ./after)

        [org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/custom0]
        binding='<Super>k'
        command='kitty'
        name='Kitty'

        In this example, the user set a keybinding for the kitty terminal between capturing the
        "before" and "after" snapshots.
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Position = 0)]
        [string[]]$ReferenceObject,

        [Parameter(Position = 1)]
        [string[]]$DifferenceObject
    )

    $Ref = if ($null -eq $ReferenceObject)
    {
        Export-Dconf | ConvertTo-PSObject
        Write-Host "Captured dconf snapshot"
    }
    else
    {
        $ReferenceObject | ConvertTo-PSObject
    }

    $Diff = if ($null -eq $DifferenceObject)
    {
        $null = Read-Host "Waiting to capture snapshot (press enter after making changes)"
        Export-Dconf | ConvertTo-PSObject
    }
    else
    {
        $DifferenceObject | ConvertTo-PSObject
    }

    $DiffsByPath = Compare-Object $Ref $Diff -Property FullName, Value | Group-Object FullName
    $Changed = $DiffsByPath |
        ForEach-Object {
            if ($_.Count -gt 1)
            {
                $_.Group | Where-Object SideIndicator -eq '=>'
            }
            elseif ($_.Group[0].SideIndicator -eq '=>')
            {
                $_.Group[0]
            }
            else
            {
                $Removed = $_.Group[0]
                $Removed.Value = $Script:DCONF_RESET_SENTINEL
                $Removed
            }
        } |
        Write-Output |
        ForEach-Object {
            $Path, $Key = $_.FullName -split '/(?=[^/]+/?$)', 2
            [Dconf.KeyInfo]::new($Path, $Key, $_.Value)
        }

    $Changed | ConvertTo-Dconf
}

function Export-Dconf
{
    <#
        .SYNOPSIS
        Exports dconf settings.

        .DESCRIPTION
        Exports dconf settings within a given dconf path to stdout or to file. Wraps `dconf dump`
        and converts dconf paths to absolute paths.

        .PARAMETER Path
        The dconf path to export.

        .PARAMETER OutFile
        The file to export to. By default, this command writes to stdout.

        .EXAMPLE
        Export-Dconf org/gnome/shell/extensions ./dconf.dump

        Exports settings under `/org/gnome/shell/extensions/` to `dconf.dump`.
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Position = 0)]
        [string[]]$Path = "/",

        [Parameter(Position = 1)]
        [string]$OutFile
    )

    $Content = @()

    foreach ($_Path in $Path)
    {
        $_Path = $_Path -replace '^/?', '/' -replace '(?<=[^/])$', '/'
        $Content += dconf dump $_Path | Resolve-DconfPath -Path $_Path
    }

    $Content = ($Content | Out-String).Trim()
    if ($OutFile)
    {
        $Content > $OutFile
    }
    elseif ($Content)
    {
        $Content
    }
}

function Import-Dconf
{
    <#
        .SYNOPSIS
        Imports dconf settings from file.

        .DESCRIPTION
        Imports dconf settings from file. Backs up all settings before importing.

        .PARAMETER File
        Path to file containing dconf settings. The file can be generated with `Export-Dconf` or
        with `dconf dump /`. Content should be key-value pairs, section headers in square brackets.

        Note that when using `dconf dump`, the section headers will be relative to the path passed
        to `dconf dump`. If this was not `/` (the root path), then the import will succeed, but
        incorrect paths and keys will be created. Export-Dconf prevents this by resolving paths to
        absolute paths.

        .PARAMETER Filter
        Limit the dconf paths to import. Only keys that are children of the dconf paths will be
        imported.

        .PARAMETER SkipBackup
        Do not back up dconf settings before import.

        .PARAMETER BackupPath
        Path to backup file. By default, this will be `/tmp/dconf.xxxxxxxxxxxxxxxxxx`.

        .EXAMPLE
        Import-Dconf -File ./dconf.dump

        Imports all settings from `dconf.dump`.

        .EXAMPLE
        Import-Dconf -File ./dconf.dump -Filter org/gnome/shell/extensions

        Imports settings under `/org/gnome/shell/extensions/` from `dconf.dump`.
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, Position = 0)]
        [string]$File,

        [string[]]$Filter,

        [switch]$SkipBackup,

        [string]$BackupPath = (Join-Path ([IO.Path]::GetTempPath()) "dconf.$([datetime]::UtcNow.Ticks)")
    )

    end
    {
        if (-not $SkipBackup)
        {
            Export-Dconf / -OutFile $BackupPath
            "Backed up dconf settings to $BackupPath" | Write-Verbose
        }

        $Content = Get-Content $File -ErrorAction Stop
        $Content | Set-Dconf -Filter $Filter
    }
}

#endregion public

$Script:DCONF_RESET_SENTINEL = "<default>"

$PathCompleter = {
    param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    $HasLeadingSlash = $wordToComplete -match '^/'
    $wordToComplete = $wordToComplete -replace '^/'
    $Paths = @(Get-DconfPath) -ilike "*$wordToComplete*"
    $DirectChildren = @($Paths) -imatch "^$wordToComplete([^/]*)$"
    $Paths = $DirectChildren, $Paths | Write-Output | Select-Object -Unique
    if ($HasLeadingSlash)
    {
        $Paths = @($Paths) -replace '^/?', '/'
    }
    $Paths
}
Register-ArgumentCompleter -CommandName Set-Dconf, Export-Dconf -ParameterName Path -ScriptBlock $PathCompleter
Register-ArgumentCompleter -CommandName Import-Dconf -ParameterName Filter -ScriptBlock $PathCompleter