Private/Kinds/Nextcloud.ps1

# The Nextcloud Kind: whether the Nextcloud client is keeping up, read from its own journal.
#
# The counterpart of the OneDrive Kind for the other sync client a Customer runs. The
# Nextcloud client keeps, in every folder it synchronises, a journal: an SQLite file
# (.sync_<id>.db) with a row for every file it knows and, apart from those, the files it
# could not transfer and has set aside, the conflicts nobody has resolved, and the
# transfers that are under way. Observed 2026-10-13 on a workstation with one account
# and one folder: 19621 files, 11534 of them only in the cloud, 5682 folders, nothing set
# aside and no conflict.
#
# What is read:
#
# - nextcloud.cfg, the client's configuration: the version, each account's server, and
# each folder with where it lies, whether it is paused and whether it keeps files
# only in the cloud. Of a server its name is kept; no user name and no certificate.
# - The journal of each folder: the counts, and the files set aside by what the client
# wrote as the reason - never by their path. Which file it is, is what a User keeps
# there, and a Run reads state and not content. See ADR-0006.
#
# The client holds its journal locked against every other reader, so it is read as a
# file nobody writes to (Private\Sqlite.ps1). That shows the journal as of its last
# change on disk, and misses what the client has noted since and not yet written back:
# on the machine observed that was ten hours. A Finding says how old what it read is.

# The kinds of entry in the journal's list of files, as the client numbers them.
$script:NextcloudTypeFile        = 0
$script:NextcloudTypeFolder      = 2
$script:NextcloudTypePlaceholder = 4

function ConvertFrom-NextcloudConfig {
    <#
    .SYNOPSIS
        The text of nextcloud.cfg as the client's version and its folders. Pure.
    .DESCRIPTION
        The file is an ini as Qt writes it. Under [Accounts] a key is a path:
        "0\url" is the server of account 0, and "0\FoldersWithPlaceholders\1\localPath"
        is where folder 1 of that account lies. A folder stands under "Folders",
        "FoldersWithPlaceholders" or "Multifolders", by how it was set up.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyString()][string]$Text)

    $section = ''
    $version = ''
    $servers = @{}
    $folders = [ordered]@{}
    foreach ($line in ("$Text" -split "`r?`n")) {
        if ($line -match '^\s*\[(.+)\]\s*$') { $section = $Matches[1]; continue }
        if ($line -notmatch '^\s*([^=]+?)\s*=\s*(.*?)\s*$') { continue }
        $name  = $Matches[1]
        $value = $Matches[2].Trim('"')
        if ($section -eq 'General' -and $name -eq 'clientVersion') { $version = $value; continue }
        if ($section -ne 'Accounts') { continue }
        if ($name -match '^(\d+)\\url$') {
            $server = $value
            try { $server = ([uri]$value).Host } catch { $server = $value }
            $servers[$Matches[1]] = "$server"
            continue
        }
        if ($name -match '^(\d+)\\(?:Folders|FoldersWithPlaceholders|Multifolders)\\([^\\]+)\\(localPath|journalPath|paused|virtualFilesMode)$') {
            $key = '{0}|{1}' -f $Matches[1], $Matches[2]
            if (-not $folders.Contains($key)) { $folders[$key] = @{ Account = $Matches[1] } }
            $folders[$key][$Matches[3]] = $value
        }
    }
    [pscustomobject]@{
        Version = $version
        Folders = @($folders.Keys | ForEach-Object {
            $one = $folders[$_]
            # Qt writes a path with forward slashes, and a backslash doubled.
            $path = ("$($one['localPath'])" -replace '\\\\', '\' -replace '/', '\').TrimEnd('\')
            if (-not $path) { return }
            [pscustomobject]@{
                Account = "$($one['Account'])"
                Server  = "$($servers[$one['Account']])"
                Path    = $path
                Journal = "$($one['journalPath'])"
                Paused  = ("$($one['paused'])" -eq 'true')
                Virtual = ("$($one['virtualFilesMode'])" -and "$($one['virtualFilesMode'])" -ne 'off')
            }
        })
    }
}

function Read-NextcloudJournal {
    <#
    .SYNOPSIS
        What the journal of one folder says: the counts, and the files set aside by the
        reason the client wrote. Asks only; reads no path of a file. Never throws.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][string]$Path)

    $read = [ordered]@{ Read = $false; Problem = ''; Files = $null; Folders = $null; Placeholders = $null
        Errors = @(); Conflicts = $null; CaseConflicts = $null; Uploads = $null; Downloads = $null }
    try {
        $types = @{}
        foreach ($row in (Read-SqliteFile -Path $Path -Query 'select type, count(*) from metadata group by type')) { $types["$($row[0])"] = [int]$row[1] }
        $read.Files        = [int]$types["$script:NextcloudTypeFile"] + [int]$types["$script:NextcloudTypePlaceholder"]
        $read.Placeholders = [int]$types["$script:NextcloudTypePlaceholder"]
        $read.Folders      = [int]$types["$script:NextcloudTypeFolder"]
        $read.Read = $true
    }
    catch { $read.Problem = "$($_.Exception.GetBaseException().Message)"; return [pscustomobject]$read }

    # Each of these by itself: a journal of an older client lacks a table, and that is
    # no reason to lose the others.
    try {
        $read.Errors = @(Read-SqliteFile -Path $Path -Query 'select errorCategory, errorstring, count(*), max(lastTryTime) from blacklist group by errorCategory, errorstring order by 3 desc limit 20' | ForEach-Object {
            $last = $null
            $seconds = ConvertTo-Number $_[3]
            if ($null -ne $seconds -and $seconds -gt 0) { $last = ([datetime]'1970-01-01T00:00:00Z').ToUniversalTime().AddSeconds($seconds).ToLocalTime() }
            [pscustomobject]@{ Category = ConvertTo-Number $_[0]; Text = "$($_[1])".Trim(); Count = [int]$_[2]; Last = $last }
        })
    }
    catch { $read.Errors = @() }
    foreach ($pair in @(@('Conflicts', 'conflicts'), @('CaseConflicts', 'caseconflicts'), @('Uploads', 'uploadinfo'), @('Downloads', 'downloadinfo'))) {
        try { $read[$pair[0]] = [int]@(Read-SqliteFile -Path $Path -Query ('select count(*) from {0}' -f $pair[1]))[0][0] } catch { $read[$pair[0]] = $null }
    }
    [pscustomobject]$read
}

function Get-NextcloudData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $configPath = [Environment]::ExpandEnvironmentVariables("$(Get-Parameter $Parameters 'ConfigPath' '%APPDATA%\Nextcloud\nextcloud.cfg')")
    $config = $null
    if (Test-Path -LiteralPath $configPath -PathType Leaf) {
        try { $config = ConvertFrom-NextcloudConfig -Text (Read-AppLogFile -Path $configPath) } catch { $config = $null }
    }

    $folders = @((Get-DataCollection $config 'Folders') | Where-Object { $_ } | ForEach-Object {
        $exists  = [bool](Test-Path -LiteralPath $_.Path -PathType Container)
        $journal = $null
        $written = $null
        $file    = $(if ($_.Journal) { Join-Path $_.Path $_.Journal } else { '' })
        if ($exists -and $file -and (Test-Path -LiteralPath $file -PathType Leaf)) {
            $written = (Get-Item -LiteralPath $file -Force).LastWriteTime
            $journal = Read-NextcloudJournal -Path $file
        }
        [pscustomobject]@{
            Account        = $_.Account
            Server         = $_.Server
            Path           = $_.Path
            Exists         = $exists
            Paused         = $_.Paused
            Virtual        = $_.Virtual
            # $null where there is no journal: not a journal that names no error.
            Journal        = $journal
            JournalWritten = $written
        }
    })

    $running = $null
    try { $running = [bool]@(Get-Process -Name 'nextcloud' -ErrorAction SilentlyContinue).Count } catch { $running = $null }

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.Data.Nextcloud'
        App        = "$(Get-Parameter $Parameters 'App' 'Nextcloud')"
        Configured = $null -ne $config
        Version    = "$(Get-DataProperty $config 'Version')"
        Folders    = $folders
        Running    = $running
        GatheredAt = Get-Date
    }
}

function New-NextcloudSyncFinding {
    <#
    .SYNOPSIS
        Whether one folder is keeping up: the files the client set aside and why, the
        conflicts nobody resolved, what is under way, and how old the journal is.
    .DESCRIPTION
        A WARN where the client set files aside or a conflict is open: both stay until
        somebody does something. A transfer under way and a folder that is paused are
        said and are no fault.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Folder, [AllowNull()]$Data, [hashtable]$Parameters)

    $path  = "$(Get-DataProperty $Folder 'Path')"
    $check = (Get-Text 'Check.Nextcloud.Sync') -f $path

    if ((Get-DataProperty $Data 'Running') -eq $false) {
        return New-Finding -Category Apps -Check $check -Severity WARN -Value (Get-Text 'Value.Nextcloud.NotRunning') `
            -Meaning (Get-Text 'Meaning.Nextcloud.NotRunning') -Hint (Get-Text 'Hint.Nextcloud.NotRunning')
    }
    if ((Get-DataProperty $Folder 'Exists') -eq $false) {
        return New-Finding -Category Apps -Check $check -Severity WARN -Value (Get-Text 'Value.Nextcloud.FolderMissing') `
            -Meaning (Get-Text 'Meaning.Nextcloud.FolderMissing') -Hint (Get-Text 'Hint.Nextcloud.FolderMissing')
    }
    $journal = Get-DataProperty $Folder 'Journal'
    if ($null -eq $journal -or -not [bool](Get-DataProperty $journal 'Read')) {
        $value = Get-Text 'Value.Nextcloud.NoJournal'
        $problem = "$(Get-DataProperty $journal 'Problem')"
        if ($problem -eq 'NoSqlite') { $value = Get-Text 'Value.Nextcloud.NoSqlite' }
        return New-Finding -Category Apps -Check $check -Severity INFO -Value $value -Hint (Get-Text 'Hint.Nextcloud.NoJournal')
    }

    $errors    = @((Get-DataCollection $journal 'Errors') | Where-Object { $_ })
    $failed    = [int](($errors | ForEach-Object { ConvertTo-Number (Get-DataProperty $_ 'Count') } | Measure-Object -Sum).Sum)
    $conflicts = [int](ConvertTo-Number (Get-DataProperty $journal 'Conflicts')) + [int](ConvertTo-Number (Get-DataProperty $journal 'CaseConflicts'))
    $moving    = [int](ConvertTo-Number (Get-DataProperty $journal 'Uploads')) + [int](ConvertTo-Number (Get-DataProperty $journal 'Downloads'))
    $paused    = [bool](Get-DataProperty $Folder 'Paused')

    $value = @()
    if ($failed) {
        $reasons = @($errors | Select-Object -First 5 | ForEach-Object {
            $text = "$(Get-DataProperty $_ 'Text')"
            if (-not $text) { $text = Get-Text 'Value.Nextcloud.NoReason' }
            # The one category the client keeps apart: the server is full.
            if ((ConvertTo-Number (Get-DataProperty $_ 'Category')) -eq 1) { $text = (Get-Text 'Value.Nextcloud.ServerFull') -f $text }
            '{0} {1}x' -f ($text -replace ';', ','), [int](ConvertTo-Number (Get-DataProperty $_ 'Count'))
        })
        $value += (Get-Text 'Value.Nextcloud.Failed') -f $failed
        $value += (Get-Text 'Value.Nextcloud.Reasons') -f ($reasons -join '; ')
    }
    if ($conflicts) { $value += (Get-Text 'Value.Nextcloud.Conflicts') -f $conflicts }
    if ($moving)    { $value += (Get-Text 'Value.Nextcloud.Moving') -f $moving }
    if ($paused)    { $value += Get-Text 'Value.Nextcloud.Paused' }
    if (-not $failed -and -not $conflicts -and -not $moving -and -not $paused) { $value += Get-Text 'Value.Nextcloud.UpToDate' }
    $value += (Get-Text 'Value.Nextcloud.Counts') -f [int](ConvertTo-Number (Get-DataProperty $journal 'Files')),
        [int](ConvertTo-Number (Get-DataProperty $journal 'Folders')), [int](ConvertTo-Number (Get-DataProperty $journal 'Placeholders'))
    $written = ConvertTo-DataTime (Get-DataProperty $Folder 'JournalWritten')
    if ($null -ne $written) { $value += (Get-Text 'Value.Nextcloud.JournalAt') -f $written }

    $reference = @(New-Reference -Section 'Title.Nextcloud.Folders')
    if ($failed -or $conflicts) {
        $meaning = @()
        $hint    = @()
        if ($failed)    { $meaning += Get-Text 'Meaning.Nextcloud.Failed';    $hint += Get-Text 'Hint.Nextcloud.Failed' }
        if ($conflicts) { $meaning += Get-Text 'Meaning.Nextcloud.Conflicts'; $hint += Get-Text 'Hint.Nextcloud.Conflicts' }
        $newest = @($errors | ForEach-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Last') } | Where-Object { $null -ne $_ } | Sort-Object)[-1]
        return New-Finding -Category Apps -Check $check -Severity WARN -Value ($value -join ' | ') `
            -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') -Reference $reference -LastSeen $newest
    }
    if ($moving -or $paused) {
        return New-Finding -Category Apps -Check $check -Severity INFO -Value ($value -join ' | ') `
            -Meaning (Get-Text 'Meaning.Nextcloud.Waits') -Hint (Get-Text 'Hint.Nextcloud.Waits') -Reference $reference
    }
    New-Finding -Category Apps -Check $check -Severity OK -Value ($value -join ' | ') -Reference $reference
}

function New-NextcloudSetupFinding {
    <#
    .SYNOPSIS
        How the Nextcloud client is set up for the User of the Run: the servers, the
        folders, whether files are kept only in the cloud, and the version.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data)

    $check   = Get-Text 'Check.Nextcloud.Setup'
    $folders = @((Get-DataCollection $Data 'Folders') | Where-Object { $_ })
    if (-not $folders.Count) {
        return New-Finding -Category Apps -Check $check -Severity INFO -Value (Get-Text 'Value.Nextcloud.NoFolder') -Hint (Get-Text 'Hint.Nextcloud.NoFolder')
    }

    $servers = @($folders | ForEach-Object { "$(Get-DataProperty $_ 'Server')" } | Where-Object { $_ } | Sort-Object -Unique)
    $value = @()
    if ($servers.Count) { $value += (Get-Text 'Value.Nextcloud.Servers') -f ($servers -join ', ') }
    $value += (Get-Text 'Value.Nextcloud.FolderList') -f (@($folders | ForEach-Object {
        $mode = Get-Text 'Value.Nextcloud.Mode.Full'
        if ([bool](Get-DataProperty $_ 'Virtual')) { $mode = Get-Text 'Value.Nextcloud.Mode.Virtual' }
        (Get-Text 'Value.Nextcloud.FolderItem') -f "$(Get-DataProperty $_ 'Path')", $mode
    }) -join '; ')
    $version = "$(Get-DataProperty $Data 'Version')".Trim()
    if ($version) { $value += (Get-Text 'Value.Nextcloud.Version') -f $version }
    New-Finding -Category Apps -Check $check -Severity OK -Value ($value -join ' | ')
}

function ConvertTo-NextcloudFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    New-NextcloudSetupFinding -Data $Data
    foreach ($folder in @((Get-DataCollection $Data 'Folders') | Where-Object { $_ })) {
        New-NextcloudSyncFinding -Folder $folder -Data $Data -Parameters $Parameters
    }
}

function ConvertTo-NextcloudSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $folders = @((Get-DataCollection $Data 'Folders') | Where-Object { $_ })
    if (-not $folders.Count) { return }
    New-Section -Title (Get-Text 'Title.Nextcloud.Folders') -Help (Get-Text 'Help.Nextcloud.Folders') -Row @(
        $folders | ForEach-Object {
            $journal = Get-DataProperty $_ 'Journal'
            $read    = [bool](Get-DataProperty $journal 'Read')
            $n = { param($Name) if ($read) { ConvertTo-Number (Get-DataProperty $journal $Name) } }
            $row = [ordered]@{}
            $row[(Get-Text 'Column.Nextcloud.Folder')]       = "$(Get-DataProperty $_ 'Path')"
            $row[(Get-Text 'Column.Nextcloud.Server')]       = "$(Get-DataProperty $_ 'Server')"
            $row[(Get-Text 'Column.Nextcloud.JournalAt')]    = Get-DataProperty $_ 'JournalWritten'
            $row[(Get-Text 'Column.Nextcloud.Files')]        = & $n 'Files'
            $row[(Get-Text 'Column.Nextcloud.Placeholders')] = & $n 'Placeholders'
            $row[(Get-Text 'Column.Nextcloud.Folders')]      = & $n 'Folders'
            $row[(Get-Text 'Column.Nextcloud.Failed')]       = $(if ($read) { [int](((Get-DataCollection $journal 'Errors') | ForEach-Object { ConvertTo-Number (Get-DataProperty $_ 'Count') } | Measure-Object -Sum).Sum) })
            $row[(Get-Text 'Column.Nextcloud.Conflicts')]    = $(if ($read) { [int](ConvertTo-Number (Get-DataProperty $journal 'Conflicts')) + [int](ConvertTo-Number (Get-DataProperty $journal 'CaseConflicts')) })
            $row[(Get-Text 'Column.Nextcloud.Moving')]       = $(if ($read) { [int](ConvertTo-Number (Get-DataProperty $journal 'Uploads')) + [int](ConvertTo-Number (Get-DataProperty $journal 'Downloads')) })
            $row[(Get-Text 'Column.Nextcloud.Paused')]       = Get-TriStateText ([bool](Get-DataProperty $_ 'Paused'))
            $row[(Get-Text 'Column.Nextcloud.Virtual')]      = Get-TriStateText ([bool](Get-DataProperty $_ 'Virtual'))
            [pscustomobject]$row
        }
    )

    $rows = @($folders | ForEach-Object {
        $path = "$(Get-DataProperty $_ 'Path')"
        (Get-DataCollection (Get-DataProperty $_ 'Journal') 'Errors') | Where-Object { $_ } | ForEach-Object {
            $row = [ordered]@{}
            $row[(Get-Text 'Column.Nextcloud.Folder')] = $path
            $row[(Get-Text 'Column.Nextcloud.Count')]  = [int](ConvertTo-Number (Get-DataProperty $_ 'Count'))
            $row[(Get-Text 'Column.Nextcloud.Reason')] = "$(Get-DataProperty $_ 'Text')"
            $row[(Get-Text 'Column.Nextcloud.Last')]   = Get-DataProperty $_ 'Last'
            [pscustomobject]$row
        }
    })
    if ($rows.Count) { New-Section -Title (Get-Text 'Title.Nextcloud.Errors') -Help (Get-Text 'Help.Nextcloud.Errors') -Row $rows }
}