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 and its address are kept, without a user
# name or a password the address may carry; 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. A client up to 3.16
# wrote the URL of the request into the reason: it is said in full, as the client
# wrote it (#176).
# - Each file set aside once more, as when the client last tried it and why, for the
# Timeline (ADR-0010): on the account's server, on the port its address names. The
# journal keeps one row per file and drops it once the file syncs, so these are the
# files still failing and not a history of what failed.
# - Of each account whether it can sign in (#185): how it signs in (authType) and whether
# a user is set, from nextcloud.cfg, and whether a credential is saved for it, by the
# target names cmdkey /list prints and never by the secret (ADR-0016). The client
# saves it under Nextcloud_<user>:<url>:<n>, and the app password of a web sign-in
# under Nextcloud_<id>_app-password:<url>:<n>, n the account's number in
# nextcloud.cfg (docs/research/nextcloud-sign-in.md). A target is matched to its
# account by the address and the number, so the user is compared with nothing.
# - Each account's server, asked without credentials (#179): status.php, whether it is in
# maintenance or waits for its database to be upgraded, and its version; and the
# schemes the WWW-Authenticate of an anonymous request to remote.php/dav/ offers
# (Basic, Bearer, Negotiate). Kept on each account as Schemes, the names alone, for
# whatever asks whether Windows sign-in could work. Measured as the HttpsEndpoint Kind
# measures a request, on the account's Service.
#
# The credential store, nextcloud.cfg and the journal are the User's: read from an elevated
# Run, or from one that runs as another account (RunUser.Differs), they are somebody
# else's, and the account's Finding says it could not check rather than that all is well.
#
# 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

# What the Judge observes of how things are connected (ADR-0012): each account's server a
# Service by its address. The Run adds that the App uses it.
$script:NextcloudLinks = @('OnServer:Service')

# How many of the files set aside are read one by one, the newest first: enough for the
# Timeline, and a journal of thousands of them does not become thousands of events. The
# counts by reason are of them all.
$script:NextcloudFailureLimit = 200

# How much of status.php's answer is kept: a JSON object of a few hundred bytes.
$script:NextcloudStatusBytes = 4096

function ConvertFrom-NextcloudJournalTime {
    <#
    .SYNOPSIS
        A time as the journal keeps it, seconds since 1970 in UTC, as local time. Nothing
        for none. Pure.
    #>

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

    $number = ConvertTo-Number $Seconds
    if ($null -eq $number -or $number -le 0) { return $null }
    ([datetime]'1970-01-01T00:00:00Z').ToUniversalTime().AddSeconds($number).ToLocalTime()
}

function ConvertFrom-NextcloudConfig {
    <#
    .SYNOPSIS
        The text of nextcloud.cfg as the client's version, its accounts 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. Read as any ini
        file is (ConvertFrom-IniText), as the AppLog Kind reads the account's server out of
        it for the client's own log.
    #>

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

    $version = ''
    $servers = @{}
    $urls = @{}
    $folders = [ordered]@{}
    # Of each account how it signs in and whether a user is set - never who the user is: a
    # saved credential is matched by the address and the account's number
    # (docs/research/nextcloud-sign-in.md).
    $signIn = [ordered]@{}
    foreach ($entry in (ConvertFrom-IniText -Text $Text)) {
        $section = $entry.Section
        $name    = $entry.Name
        $value   = $entry.Value
        if ($section -eq 'General' -and $name -eq 'clientVersion') { $version = $value; continue }
        if ($section -ne 'Accounts') { continue }
        if ($name -match '^(\d+)\\(authType|dav_user|webflow_user|serverVersion)$') {
            if (-not $signIn.Contains($Matches[1])) { $signIn[$Matches[1]] = @{} }
            $signIn[$Matches[1]][$Matches[2]] = "$value".Trim()
            continue
        }
        if ($name -match '^(\d+)\\url$') {
            $account = $Matches[1]
            if (-not $signIn.Contains($account)) { $signIn[$account] = @{} }
            $server = $value
            $url = ''
            try {
                $uri = [uri]$value
                $server = $uri.Host
                # The address without a user or a password it may carry.
                $url = $uri.GetComponents([UriComponents]::SchemeAndServer -bor [UriComponents]::Path, [UriFormat]::UriEscaped)
                if (-not $uri.AbsolutePath.Trim('/') -and -not $url.EndsWith('/')) { $url += '/' }
            }
            catch { $server = $value }
            $servers[$account] = "$server"
            $urls[$account] = "$url"
            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
        Accounts = @($signIn.Keys | Where-Object { $urls.ContainsKey($_) -and $urls[$_] } | ForEach-Object {
            $one = $signIn[$_]
            [pscustomobject]@{
                Account       = "$_"
                Server        = "$($servers[$_])"
                Url           = "$($urls[$_])"
                AuthType      = "$($one['authType'])"
                HasUser       = [bool]("$($one['dav_user'])" -or "$($one['webflow_user'])")
                # The server's version as the client last saw it, when it connected.
                ServerVersion = "$($one['serverVersion'])"
            }
        })
        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']])"
                Url     = "$($urls[$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 but the URL a reason
        names. Never throws.
    #>

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

    $read = [ordered]@{ Read = $false; Problem = ''; Files = $null; Folders = $null; Placeholders = $null
        Errors = @(); Failures = @(); 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 {
            [pscustomobject]@{ Category = ConvertTo-Number $_[0]; Text = "$($_[1])".Trim(); Count = [int]$_[2]; Last = ConvertFrom-NextcloudJournalTime $_[3] }
        })
    }
    catch { $read.Errors = @() }
    # Each by its last attempt and its reason, and not by its path.
    try {
        $read.Failures = @(Read-SqliteFile -Path $Path -Query ('select errorCategory, errorstring, lastTryTime from blacklist order by lastTryTime desc limit {0}' -f $script:NextcloudFailureLimit) | ForEach-Object {
            [pscustomobject]@{ Category = ConvertTo-Number $_[0]; Text = "$($_[1])".Trim(); Time = ConvertFrom-NextcloudJournalTime $_[2] }
        })
    }
    catch { $read.Failures = @() }
    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
}

# What Get-NextcloudData hands its Judge. See Private/DataShape.ps1.
$script:NextcloudDataShape = @{
    App                   = 'Value'
    Configured            = 'Answered'
    Version               = 'Value'
    Accounts              = 'List'
    Folders               = 'List'
    Running               = 'Value'
    SavedCredentials      = 'List'
    SavedCredentialsError = 'Value'
    IsElevated            = 'Answered'
    RunUser               = 'Value'
    Prefixes              = 'List'
    Routes                = 'List'
    GatheredAt            = 'Answered'
}

function Get-NextcloudData {
    <#
    .PARAMETER Observed
        What earlier Checks gathered. Only 'RunUser' is read: whose Session the Run is in.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [hashtable]$Parameters = @{},
        [AllowNull()][hashtable]$Observed = @{}
    )

    $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
            Url            = $_.Url
            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 }

    # Each account's server asked, without credentials, what state it is in and how it wants
    # to be signed in to (#179). Once per address, however many accounts share it.
    $asked = @{}
    $accounts = @((Get-DataCollection $config 'Accounts') | Where-Object { $_ } | ForEach-Object {
        $url = "$($_.Url)"
        if (-not $asked.ContainsKey($url)) { $asked[$url] = Invoke-NextcloudServerProbe -Url $url }
        $answer = $asked[$url]
        $service = ConvertTo-UrlService -Url $url
        [pscustomobject]@{
            Account       = $_.Account
            Server        = $_.Server
            Port          = $(if ($service) { $service.Port } else { $null })
            Url           = $url
            AuthType      = $_.AuthType
            HasUser       = $_.HasUser
            ServerVersion = $_.ServerVersion
            AskedAt       = $answer.AskedAt
            StatusProbe   = $answer.StatusProbe
            DavProbe      = $answer.DavProbe
            Schemes       = $answer.Schemes
        }
    })

    # What the Judge decides each server's Zone from, as the HttpsEndpoint Kind reads it.
    $addresses = @($asked.Values | ForEach-Object { "$($_.StatusProbe.RemoteAddress)" } | Where-Object { $_ } | Select-Object -Unique)

    # The user's own store, by target name: only the client's own targets leave this block,
    # every other entry is someone's unrelated account. Not asked where there is no account.
    $credentials      = @()
    $credentialsError = $null
    if ($accounts.Count) {
        $saved = Read-CredentialTargetName
        $credentialsError = $saved.Error
        $credentials = @(@($saved.Targets) | Where-Object { "$_" -like 'Nextcloud_*' })
    }

    # Whose Session the Run is in, as the Run decided it once for every Check about the
    # User. Absent where this Check is performed without a Run around it.
    $runUser = Get-ObservedRunUser -Observed $Observed
    if ($null -eq $runUser) {
        $runUser = ConvertTo-RunUserObserved -RunningAs ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LoggedOnUser (Get-InteractiveUser)
    }

    [pscustomobject]@{
        PSTypeName            = 'Gutcheck.Data.Nextcloud'
        App                   = "$(Get-Parameter $Parameters 'App' 'Nextcloud')"
        Configured            = $null -ne $config
        Version               = "$(Get-DataProperty $config 'Version')"
        Accounts              = $accounts
        Folders               = $folders
        Running               = $running
        # Target names only, the client's own. SavedCredentialsError says where the store
        # could not be listed: then none is not "none saved".
        SavedCredentials      = $credentials
        SavedCredentialsError = $credentialsError
        IsElevated            = (Get-CurrentPrivilege) -eq 'admin'
        RunUser               = $runUser
        Prefixes              = @(if ($addresses.Count) { Get-OwnPrefix })
        Routes                = @($addresses | ForEach-Object { Get-AddressRoute -Address $_ })
        GatheredAt            = Get-Date
    }
}

function Invoke-NextcloudServerProbe {
    <#
    .SYNOPSIS
        Asks one Nextcloud server, without credentials, what state it is in (status.php)
        and how it wants to be signed in to (the WWW-Authenticate of remote.php/dav):
        AskedAt, StatusProbe, DavProbe and Schemes, the names of the schemes it offered.
        Judges nothing, and never throws.
    .DESCRIPTION
        Through the HttpsEndpoint Kind's probe, which has no way to send a credential, a
        cookie or an Authorization header (ADR-0016). Neither request is a sign-in: the
        server answers status.php to anybody, and remote.php/dav without an Authorization
        header with its challenge alone. Nextcloud counts a failed sign-in towards its
        brute-force throttling only where a user and a password were tried
        (apps/dav/lib/Connector/Sabre/Auth.php, validateUserPass, reached from Sabre's
        AbstractBasic only with an "Authorization: Basic" header), so neither request slows
        down the User's own client.
    #>

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

    $base = "$Url".TrimEnd('/') + '/'
    $askedAt = Get-Date
    $status = Invoke-HttpsEndpointProbe -Target ([pscustomobject]@{
        Key = 'Status'; Method = 'GET'; BearerProbe = $false; ReadBodyBytes = $script:NextcloudStatusBytes; Url = $base + 'status.php' })
    $dav = Invoke-HttpsEndpointProbe -Target ([pscustomobject]@{
        Key = 'Dav'; Method = 'GET'; BearerProbe = $false; Url = $base + 'remote.php/dav/' })
    [pscustomobject]@{
        AskedAt     = $askedAt
        StatusProbe = $status
        DavProbe    = $dav
        Schemes     = [string[]]@(@($dav.WwwAuthenticate) | ForEach-Object { ConvertFrom-WwwAuthenticate -Value $_ } |
            ForEach-Object { "$($_.Scheme)" } | Where-Object { $_ } | Select-Object -Unique)
    }
}

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 += $(if ($failed -eq 1) { Get-Text 'Value.Nextcloud.Failed.One' } else { Get-Text 'Value.Nextcloud.Failed' $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]
        # The server refused the account for some of them, where the timeline draws it so.
        # ADR-0005.
        $signal = @(Get-TimedEventDeniedSignal -TimedEvent @(ConvertTo-NextcloudTimedEvent -Folder $Folder))
        # A refusal is the Service's that refused, as the timeline draws it: the account's
        # host:port, which a Lead joins to the cause it finds on the same Service (#183).
        # Files set aside for anything else are the folder's, and about no Service.
        $about = @{}
        if ($signal -contains 'service-denied') {
            $service = ConvertTo-UrlService -Url "$(Get-DataProperty $Folder 'Url')"
            $about.Subject = $(if ($service) { $service.Subject } else { Format-ServiceSubject -Server "$(Get-DataProperty $Folder 'Server')".Trim() -Port 443 })
        }
        return New-Finding -Category Apps -Check $check -Severity WARN -Value ($value -join ' | ') `
            -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') -Reference $reference -LastSeen $newest -Signal $signal @about
    }
    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 ' | ')
}

# A credential the client saved for an account, by its target name: Nextcloud_, the user or
# the id of an app password, then the account's address and its number in nextcloud.cfg
# (docs/research/nextcloud-sign-in.md). The address holds colons of its own, so the number
# is the digits after the last one.
$script:NextcloudCredentialPattern = '^Nextcloud_.*?:(?<url>https?://.+):(?<account>\d+)$'

function Select-NextcloudCredentialTarget {
    <#
    .SYNOPSIS
        The target names of the credentials saved for one account: those that end in its
        address and its number. Pure.
    .DESCRIPTION
        Matched by the address and the number, and not by the user the name begins with,
        so that the user is compared with nothing. The address is compared without regard
        to case and to a trailing slash.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()][AllowEmptyCollection()][string[]]$Target,
        [AllowNull()][AllowEmptyString()][string]$Url,
        [AllowNull()][AllowEmptyString()][string]$Account
    )

    $want = "$Url".Trim().TrimEnd('/')
    if (-not $want -or "$Account" -notmatch '^\d+$') { return }
    foreach ($name in @($Target | Where-Object { $_ })) {
        if ("$name" -notmatch $script:NextcloudCredentialPattern) { continue }
        if ([int]$Matches['account'] -ne [int]$Account) { continue }
        if ($Matches['url'].TrimEnd('/') -ne $want) { continue }
        "$name"
    }
}

function Get-NextcloudRefusal {
    <#
    .SYNOPSIS
        How often an account's server refused it and has not let it in since: the attempts
        drawn Denied after the newest one it did not refuse, with Count, Since and Last.
        Nothing where there is none. Pure.
    .DESCRIPTION
        The journal keeps no time of the last sync that went through: a file that syncs is
        dropped from it (ConvertTo-NextcloudTimedEvent). So each refusal it still holds is
        an attempt at a file that has not synced since. An attempt the server answered
        otherwise - a file locked, the server full - got past the sign-in, and the
        refusals before it are no longer the account's state: they are counted only where
        they are newer than every such attempt. Read off the events as the timeline draws
        them, so that the count is what the Service's lane shows.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Account, [AllowNull()]$Data)

    $number = "$(Get-DataProperty $Account 'Account')"
    $url    = "$(Get-DataProperty $Account 'Url')".TrimEnd('/')
    $events = @((Get-DataCollection $Data 'Folders') | Where-Object {
            $_ -and ("$(Get-DataProperty $_ 'Account')" -eq $number -or ($url -and "$(Get-DataProperty $_ 'Url')".TrimEnd('/') -eq $url))
        } | ForEach-Object { ConvertTo-NextcloudTimedEvent -Folder $_ } | Where-Object { $_ -and "$($_.Lane)" -eq 'Server' })

    $passed = @($events | Where-Object { "$($_.Mark)" -ne 'Denied' } | ForEach-Object { $_.Time } | Sort-Object)
    $newestPassed = $(if ($passed.Count) { $passed[-1] } else { $null })
    $refused = @($events | Where-Object { "$($_.Mark)" -eq 'Denied' -and ($null -eq $newestPassed -or $_.Time -gt $newestPassed) } |
        ForEach-Object { $_.Time } | Sort-Object)
    if (-not $refused.Count) { return }
    [pscustomobject]@{ Count = $refused.Count; Since = $refused[0]; Last = $refused[-1] }
}

function Get-NextcloudAuthTypeText {
    <#
    .SYNOPSIS
        How an account signs in, in words: the client's authType. One this has no word for
        as the client wrote it; none as that. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$AuthType)

    # The values the client writes, by how the account was set up: only webflow was seen
    # (docs/research/nextcloud-sign-in.md); the others are the client's other kinds.
    $type = "$AuthType".Trim()
    switch ($type.ToLowerInvariant()) {
        ''           { return (Get-Text 'Value.Nextcloud.AuthType.None') }
        'webflow'    { return (Get-Text 'Value.Nextcloud.AuthType.Webflow') }
        'basic'      { return (Get-Text 'Value.Nextcloud.AuthType.Basic') }
        'oauth'      { return (Get-Text 'Value.Nextcloud.AuthType.OAuth') }
        'shibboleth' { return (Get-Text 'Value.Nextcloud.AuthType.Shibboleth') }
    }
    $type
}

function New-NextcloudSignInFinding {
    <#
    .SYNOPSIS
        Whether one account can sign in: a credential saved for it, and whether its server
        refused it since it last let it in. On the account's Service, host:443. ADR-0015,
        ADR-0016.
    .DESCRIPTION
        A WARN where no credential is saved (credential-missing) - the client cannot sign
        in without asking - and where the server refused the account after the last attempt
        it let through (credential-refused), with how often and since when. Where the Run is
        not in the User's Session it says it could not check, and what it would have looked
        for, never that all is well.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Account, [AllowNull()]$Data)

    $server  = "$(Get-DataProperty $Account 'Server')"
    $url     = "$(Get-DataProperty $Account 'Url')"
    $service = ConvertTo-UrlService -Url $url
    $subject = $(if ($service) { $service.Subject } else { $server })
    $check   = (Get-Text 'Check.Nextcloud.SignIn') -f $server

    $session = Get-UserSessionObstacle -RunUser (Get-DataProperty $Data 'RunUser') -IsElevated (Get-DataProperty $Data 'IsElevated')
    if ($session) {
        $value = Get-Text 'Value.Nextcloud.SignIn.Elevated'
        if ($session -eq 'OtherUser') {
            $value = (Get-Text 'Value.Nextcloud.SignIn.OtherUser') -f "$(Get-DataProperty (Get-DataProperty $Data 'RunUser') 'LoggedOnUser')"
        }
        return New-Finding -Category Apps -Check $check -Severity INFO -Subject $subject -Value $value `
            -Hint (Get-Text 'Hint.Nextcloud.SignIn.UserSession') -Unobserved @('credential-missing', 'credential-refused')
    }

    $storeError = "$(Get-DataProperty $Data 'SavedCredentialsError')".Trim()
    $saved   = @(Select-NextcloudCredentialTarget -Target (Get-DataCollection $Data 'SavedCredentials') -Url $url -Account "$(Get-DataProperty $Account 'Account')")
    $missing = -not $storeError -and -not $saved.Count
    $refusal = Get-NextcloudRefusal -Account $Account -Data $Data

    $value = @((Get-Text 'Value.Nextcloud.SignIn.Type') -f (Get-NextcloudAuthTypeText (Get-DataProperty $Account 'AuthType')))
    if ($storeError)  { $value += (Get-Text 'Value.Nextcloud.SignIn.StoreUnread') -f $storeError }
    elseif ($missing) { $value += Get-Text 'Value.Nextcloud.SignIn.Missing' }
    else              { $value += Get-Text 'Value.Nextcloud.SignIn.Saved' }
    if (-not [bool](Get-DataProperty $Account 'HasUser')) { $value += Get-Text 'Value.Nextcloud.SignIn.NoUser' }
    if ($refusal) { $value += (Get-Text 'Value.Nextcloud.SignIn.Refused') -f $refusal.Count, $refusal.Since, $refusal.Last }

    $reference = @(New-Reference -Section 'Title.Nextcloud.SignIn')
    $signal  = @()
    $meaning = @()
    $hint    = @()
    if ($missing) { $signal += 'credential-missing'; $meaning += Get-Text 'Meaning.Nextcloud.SignIn.Missing'; $hint += Get-Text 'Hint.Nextcloud.SignIn.Missing' }
    if ($refusal) { $signal += 'credential-refused'; $meaning += Get-Text 'Meaning.Nextcloud.SignIn.Refused'; $hint += Get-Text 'Hint.Nextcloud.SignIn.Refused' }
    if ($signal.Count) {
        $lastSeen = $(if ($refusal) { $refusal.Last } else { $null })
        return New-Finding -Category Apps -Check $check -Severity WARN -Subject $subject -Value ($value -join ' | ') `
            -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') -Signal $signal -LastSeen $lastSeen -Reference $reference
    }
    if ($storeError) {
        return New-Finding -Category Apps -Check $check -Severity INFO -Subject $subject -Value ($value -join ' | ') `
            -Hint (Get-Text 'Hint.Nextcloud.SignIn.StoreUnread') -Unobserved @('credential-missing') -Reference $reference
    }
    New-Finding -Category Apps -Check $check -Severity OK -Subject $subject -Value ($value -join ' | ') -Reference $reference
}

function ConvertFrom-NextcloudStatus {
    <#
    .SYNOPSIS
        What status.php answered: Installed, Maintenance, NeedsDbUpgrade, Version and
        VersionString, or nothing where the body holds no answer of it. Pure.
    .DESCRIPTION
        The body is kept as it arrived, so a chunked answer still carries its chunk sizes;
        the JSON object is taken from the first brace to the last, as Autodiscover's is. Its
        keys as Nextcloud's status.php writes them: installed, maintenance, needsDbUpgrade,
        version ("30.0.4.1"), versionstring ("30.0.4"), productname.
    #>

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

    if (-not $Text) { return $null }
    $start = $Text.IndexOf('{')
    $end   = $Text.LastIndexOf('}')
    if ($start -lt 0 -or $end -le $start) { return $null }
    try { $json = $Text.Substring($start, $end - $start + 1) | ConvertFrom-Json -ErrorAction Stop }
    catch { return $null }
    if (-not (Test-DataPropertyPresent -InputObject $json -Name 'maintenance')) { return $null }
    $flag = { param($Name) $v = Get-DataProperty $json $Name; ($v -eq $true) -or ("$v" -eq 'true') }
    [pscustomobject]@{
        Installed      = & $flag 'installed'
        Maintenance    = & $flag 'maintenance'
        NeedsDbUpgrade = & $flag 'needsDbUpgrade'
        Version        = "$(Get-DataProperty $json 'version')".Trim()
        VersionString  = "$(Get-DataProperty $json 'versionstring')".Trim()
    }
}

function Test-NextcloudVersionDrift {
    <#
    .SYNOPSIS
        Whether the version the client last saw of its server differs from the one the
        server reports now. Compared by their numbers, "30.0.4.1" against "30.0.4.1"; not
        where either is unknown. Pure.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string]$Known, [AllowNull()][AllowEmptyString()][string]$Reported)

    $numbers = { param($Text) @([regex]::Matches("$Text", '\d+') | ForEach-Object { [int]$_.Value }) }
    $a = & $numbers $Known
    $b = & $numbers $Reported
    if (-not $a.Count -or -not $b.Count) { return $false }
    # A part one of them leaves out is 0: 30.0.4 is 30.0.4.0.
    $length = [math]::Max($a.Count, $b.Count)
    for ($i = 0; $i -lt $length; $i++) {
        $x = $(if ($i -lt $a.Count) { $a[$i] } else { 0 })
        $y = $(if ($i -lt $b.Count) { $b[$i] } else { 0 })
        if ($x -ne $y) { return $true }
    }
    $false
}

function Get-NextcloudSchemeText {
    <#
    .SYNOPSIS
        A sign-in scheme a server offers, in words with its name: Basic, Bearer, Negotiate,
        NTLM; one this has no words for by its name alone. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Scheme)

    $name = "$Scheme".Trim()
    switch ($name.ToLowerInvariant()) {
        'basic'     { return (Get-Text 'Value.Nextcloud.Scheme.Basic') }
        'bearer'    { return (Get-Text 'Value.Nextcloud.Scheme.Bearer') }
        'negotiate' { return (Get-Text 'Value.Nextcloud.Scheme.Negotiate') }
        'ntlm'      { return (Get-Text 'Value.Nextcloud.Scheme.Ntlm') }
    }
    $name
}

function Get-NextcloudProbeProblem {
    <#
    .SYNOPSIS
        Why a request to the server brought no answer of the server's, in words: the step
        it failed at and why, or the status a proxy answered with. Nothing where the server
        answered. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Probe)

    $failure = Get-DataProperty $Probe 'Error'
    if ($failure) {
        return '{0}: {1}' -f (Get-HttpsEndpointStageText (Get-DataProperty $Probe 'Stage')), "$(Get-DataProperty $failure 'Message')".Trim()
    }
    if ("$(Get-DataProperty $Probe 'AnsweredBy')" -eq 'Proxy') {
        return (Get-Text 'Value.Nextcloud.Server.ProxyAnswered') -f (Format-HttpStatus -Status (Get-DataProperty $Probe 'StatusCode') -Reason "$(Get-DataProperty $Probe 'Reason')")
    }
    ''
}

function Get-NextcloudSchemeValue {
    <#
    .SYNOPSIS
        What the anonymous request to remote.php/dav/ said of how to sign in, as a part of
        the server's Value. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Account)

    $dav = Get-DataProperty $Account 'DavProbe'
    if ($null -eq $dav) { return '' }
    $problem = Get-NextcloudProbeProblem -Probe $dav
    if ($problem) { return (Get-Text 'Value.Nextcloud.Server.SchemesNotAsked') -f $problem }
    $schemes = @((Get-DataCollection $Account 'Schemes') | ForEach-Object { Get-NextcloudSchemeText "$_" } | Where-Object { $_ })
    if ($schemes.Count) { return (Get-Text 'Value.Nextcloud.Server.Schemes') -f ($schemes -join ', ') }
    (Get-Text 'Value.Nextcloud.Server.NoSchemes') -f (Format-HttpStatus -Status (Get-DataProperty $dav 'StatusCode') -Reason "$(Get-DataProperty $dav 'Reason')")
}

function New-NextcloudServerFinding {
    <#
    .SYNOPSIS
        What one account's server said of itself when asked without credentials: in
        maintenance or not, its version, and how it wants to be signed in to. On the
        account's Service, host:443. ADR-0015, ADR-0016.
    .DESCRIPTION
        A FAIL where status.php says maintenance or needsDbUpgrade (service-maintenance):
        while that lasts the server refuses every sign-in and every sync, whatever this
        machine does. An INFO where the server reports another version than the client last
        saw of it: it was updated, and the client has not connected since. Whether the
        server can be reached is the Server Check's and the Links' to say; a request that
        brought no answer is said here as INFO, and no more.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Account)

    $probe = Get-DataProperty $Account 'StatusProbe'
    if ($null -eq $probe) { return }
    $server  = "$(Get-DataProperty $Account 'Server')"
    $service = ConvertTo-UrlService -Url "$(Get-DataProperty $Account 'Url')"
    $subject = $(if ($service) { $service.Subject } else { $server })
    $check   = (Get-Text 'Check.Nextcloud.Server') -f $server
    $askedAt = ConvertTo-DataTime (Get-DataProperty $Account 'AskedAt')
    $schemes = Get-NextcloudSchemeValue -Account $Account

    $problem = Get-NextcloudProbeProblem -Probe $probe
    $status  = $null
    if (-not $problem) {
        $status = ConvertFrom-NextcloudStatus -Text "$(Get-DataProperty $probe 'Body')"
        if ($null -eq $status) {
            $problem = (Get-Text 'Value.Nextcloud.Server.NoStatus') -f (Format-HttpStatus -Status (Get-DataProperty $probe 'StatusCode') -Reason "$(Get-DataProperty $probe 'Reason')")
        }
    }
    if ($problem) {
        $value = @((Get-Text 'Value.Nextcloud.Server.NotAsked') -f $problem)
        if ($schemes) { $value += $schemes }
        return New-Finding -Category Apps -Check $check -Severity INFO -Subject $subject -Value ($value -join ' | ') `
            -Hint (Get-Text 'Hint.Nextcloud.Server.NotAsked') -Unobserved @('service-maintenance')
    }

    $value = @()
    if ($status.Maintenance)    { $value += (Get-Text 'Value.Nextcloud.Server.Maintenance') -f $askedAt }
    if ($status.NeedsDbUpgrade) { $value += (Get-Text 'Value.Nextcloud.Server.DbUpgrade') -f $askedAt }
    if (-not $status.Maintenance -and -not $status.NeedsDbUpgrade) { $value += Get-Text 'Value.Nextcloud.Server.Ready' }
    $version = $(if ($status.VersionString) { $status.VersionString } else { $status.Version })
    if ($version) { $value += (Get-Text 'Value.Nextcloud.Server.Version') -f $version }
    $known = "$(Get-DataProperty $Account 'ServerVersion')".Trim()
    $drift = Test-NextcloudVersionDrift -Known $known -Reported $status.Version
    if ($drift) { $value += (Get-Text 'Value.Nextcloud.Server.Drift') -f $known, $status.Version }
    if ($schemes) { $value += $schemes }

    if ($status.Maintenance -or $status.NeedsDbUpgrade) {
        return New-Finding -Category Apps -Check $check -Severity FAIL -Subject $subject -Value ($value -join ' | ') `
            -Meaning (Get-Text 'Meaning.Nextcloud.Server.Maintenance') -Hint (Get-Text 'Hint.Nextcloud.Server.Maintenance') `
            -Signal @('service-maintenance')
    }
    if ($drift) {
        return New-Finding -Category Apps -Check $check -Severity INFO -Subject $subject -Value ($value -join ' | ') `
            -Meaning (Get-Text 'Meaning.Nextcloud.Server.Drift') -Hint (Get-Text 'Hint.Nextcloud.Server.Drift')
    }
    New-Finding -Category Apps -Check $check -Severity OK -Subject $subject -Value ($value -join ' | ')
}

# How the client wrote an HTTP error up to 3.16: 'Server replied "401 Unauthorized" to
# "PROPFIND https://..."'. From 3.17 on it writes a sentence, and no status.
$script:NextcloudReplyPattern = '^Server replied "(?<status>\d{3})(?: (?<reason>[^"]*))?" to "(?<request>[^"]*)"'

function Get-NextcloudReplyStatus {
    <#
    .SYNOPSIS
        The HTTP status a reason the client wrote names, or nothing. Pure.
    #>

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

    if ("$Text".Trim() -match $script:NextcloudReplyPattern) { return $Matches['status'] }
    ''
}

function Format-NextcloudReason {
    <#
    .SYNOPSIS
        A reason the client wrote, in the words of the Report: the status of an HTTP error
        with what it means, a sentence as it is. Pure.
    .DESCRIPTION
        Up to client 3.16 an HTTP error is 'Server replied "401 Unauthorized" to "PROPFIND
        https://cloud.contoso.example/remote.php/dav/files/..."', the URL said in full: the status
        is said as Format-HttpStatus says every one, with the reason the server gave where
        this has no word for it, so that no number stands alone. From 3.17 on the client
        writes a sentence chosen by the status, which is said as it is. The one category
        the client keeps apart, the server being full, is said as that.
    #>

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

    $said = "$Text".Trim()
    if ($said -match $script:NextcloudReplyPattern) {
        $status = Format-HttpStatus -Status $Matches['status'] -Reason $Matches['reason']
        $said = (Get-Text 'Value.Nextcloud.Timeline.Replied') -f $status, $Matches['request'].Trim()
    }
    if (-not $said) { $said = Get-Text 'Value.Nextcloud.NoReason' }
    if ((ConvertTo-Number $Category) -eq 1) { $said = (Get-Text 'Value.Nextcloud.ServerFull') -f $said }
    $said
}

function ConvertTo-NextcloudTimedEvent {
    <#
    .SYNOPSIS
        Each file of one folder still failing, at the client's last attempt, on the HTTPS
        Service of the account's server, and how far back the journal reaches. Pure.
        ADR-0010.
    .DESCRIPTION
        The event is the host:port Service's - cloud.contoso.example:443, on the port the
        account's address names (ConvertTo-UrlService) - which the Server Check and the
        Links draw too, and not a URL Service of its own: the client asked its server,
        whatever path it was.
 
        The journal holds a row per file only while the file fails, overwritten at each
        attempt, and drops it once the file syncs. So an event is the last attempt at a
        file that still fails, and its words say that the journal is no history: a file
        that failed the week before and syncs now is not in it. How far back it reaches is
        the oldest attempt among the rows read, a Reaches event of its Log, in the same
        words. Nothing is drawn of a folder with nothing failing.
 
        An attempt whose time cannot be read is left out: its Finding counts it, and the
        timeline cannot place it.
    .PARAMETER Finding
        The Finding about the folder, which counts its files set aside.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Folder, [AllowNull()]$Finding)

    $journal = Get-DataProperty $Folder 'Journal'
    if ($null -eq $journal -or -not [bool](Get-DataProperty $journal 'Read')) { return }
    $server = "$(Get-DataProperty $Folder 'Server')".Trim()
    if (-not $server) { return }
    $port = 443
    $service = ConvertTo-UrlService -Url "$(Get-DataProperty $Folder 'Url')"
    if ($service) { $port = $service.Port }
    $log = (Get-Text 'Value.Nextcloud.Timeline.Log') -f (Get-NextcloudFolderName -Path "$(Get-DataProperty $Folder 'Path')")
    $note = Get-Text 'Value.Nextcloud.Timeline.StillFailing'

    $oldest = $null
    foreach ($failure in @((Get-DataCollection $journal 'Failures') | Where-Object { $_ })) {
        $at = ConvertTo-DataTime (Get-DataProperty $failure 'Time')
        if ($null -eq $at) { continue }
        if ($null -eq $oldest -or $at -lt $oldest) { $oldest = $at }
        $text = "$(Get-DataProperty $failure 'Text')"
        $reason = Format-NextcloudReason -Text $text -Category (Get-DataProperty $failure 'Category')
        New-TimedEvent -Time $at -Lane Server -Mark (Get-TimedEventStatusMark -Status (Get-NextcloudReplyStatus -Text $text)) `
            -Subject $server -Port $port -Log $log -Finding $Finding `
            -Detail ((Get-Text 'Value.Nextcloud.Timeline.Failed') -f $reason, $note)
    }
    # How far back the journal reaches: its oldest row, and no history before it.
    if ($null -ne $oldest) { New-TimedEvent -Time $oldest -Lane Log -Mark Reaches -Log $log -Detail $note }
}

function Get-NextcloudFolderName {
    <#
    .SYNOPSIS
        A sync folder as the Timeline names its journal, without the User's folder in it:
        C:\Users\max.mustermann\Nextcloud is %USERPROFILE%\Nextcloud, a folder elsewhere its
        own name, D:\Daten\Nextcloud is Nextcloud. Pure.
    .DESCRIPTION
        The name of a Log is on every event of it, in the timeline the Report carries and
        in timeline.csv; as the AppLog Kind names a log by the path the Definition writes
        (Get-AppLogLogName), the User is not in it.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Path)

    $text = "$Path".Trim().TrimEnd('\', '/')
    if ($text -match '^[A-Za-z]:[\\/]Users[\\/][^\\/]+(?<rest>([\\/].*)?)$') { return ('%USERPROFILE%' + ($Matches['rest'] -replace '/', '\')) }
    @($text -split '[\\/]')[-1]
}

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

    New-NextcloudSetupFinding -Data $Data
    foreach ($folder in @((Get-DataCollection $Data 'Folders') | Where-Object { $_ })) {
        $sync = New-NextcloudSyncFinding -Folder $folder -Data $Data -Parameters $Parameters
        $sync
        # The files still failing on the Timeline, by the Finding that counted them. ADR-0010.
        ConvertTo-NextcloudTimedEvent -Folder $folder -Finding $sync
    }
    # Whether each account can sign in, on its Service. ADR-0016.
    # And what its server said of itself, asked without credentials, with the request to
    # status.php measured on the account's Service as the HttpsEndpoint Kind measures one.
    # ADR-0009. Once per address, however many accounts share it.
    $accounts = @((Get-DataCollection $Data 'Accounts') | Where-Object { $_ -and "$(Get-DataProperty $_ 'Url')" })
    $links = @()
    $measured = @{}
    foreach ($account in $accounts) {
        New-NextcloudSignInFinding -Account $account -Data $Data
        $url = "$(Get-DataProperty $account 'Url')"
        if ($measured.ContainsKey($url)) { continue }
        $measured[$url] = $true
        $serverFinding = New-NextcloudServerFinding -Account $account
        if (-not $serverFinding) { continue }
        $serverFinding
        $probe = Get-DataProperty $account 'StatusProbe'
        ConvertTo-HttpsEndpointMeasurement -Target ([pscustomobject]@{ Url = $url; HostName = "$(Get-DataProperty $account 'Server')" }) `
            -Observation $probe -Data $Data -Finding $serverFinding -VpnPattern $script:MeasurementVpnAdapterPattern
        $links += New-ServiceLink -Url $url -Address (Get-HttpsEndpointAnsweredAt -Observation $probe)
    }
    # And the server of each account, for the Network Map: once, however many folders; at
    # the address it answered from, where it was asked.
    $urls = @((Get-DataCollection $Data 'Folders') | Where-Object { $_ } | ForEach-Object { "$(Get-DataProperty $_ 'Url')" } | Where-Object { $_ } | Select-Object -Unique)
    Select-UniqueLink -Link @($links + @($urls | ForEach-Object { New-ServiceLink -Url $_ }))
}

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

    ConvertTo-NextcloudSignInSection -Data $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 }
}

function ConvertTo-NextcloudSignInSection {
    <#
    .SYNOPSIS
        Each account with how it signs in, whether a credential is saved for it and under
        which target names. Nothing where there is no account. Pure.
    .DESCRIPTION
        Read in the Session the Run is in: where that is not the User's, what is saved says
        it was not read, as the Finding does, and no target name of another account stands
        here.
    #>

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

    $accounts = @((Get-DataCollection $Data 'Accounts') | Where-Object { $_ -and "$(Get-DataProperty $_ 'Url')" })
    if (-not $accounts.Count) { return }
    $session    = Get-UserSessionObstacle -RunUser (Get-DataProperty $Data 'RunUser') -IsElevated (Get-DataProperty $Data 'IsElevated')
    $storeError = "$(Get-DataProperty $Data 'SavedCredentialsError')".Trim()
    New-Section -Title (Get-Text 'Title.Nextcloud.SignIn') -Help (Get-Text 'Help.Nextcloud.SignIn') -Row @(
        $accounts | ForEach-Object {
            $url   = "$(Get-DataProperty $_ 'Url')"
            $saved = @(Select-NextcloudCredentialTarget -Target (Get-DataCollection $Data 'SavedCredentials') -Url $url -Account "$(Get-DataProperty $_ 'Account')")
            $state = Get-TriStateText ([bool]$saved.Count)
            if ($storeError -or $session) { $state = Get-Text 'Value.Nextcloud.SignIn.Unread' }
            $row = [ordered]@{}
            $row[(Get-Text 'Column.Nextcloud.Account')]  = $url
            $row[(Get-Text 'Column.Nextcloud.AuthType')] = Get-NextcloudAuthTypeText (Get-DataProperty $_ 'AuthType')
            $row[(Get-Text 'Column.Nextcloud.HasUser')]  = Get-TriStateText ([bool](Get-DataProperty $_ 'HasUser'))
            $row[(Get-Text 'Column.Nextcloud.Saved')]    = $state
            $row[(Get-Text 'Column.Nextcloud.Target')]   = $(if (-not $session) { $saved -join '; ' })
            # What the server said of itself, asked without credentials (#179).
            $probe  = Get-DataProperty $_ 'StatusProbe'
            $status = $(if ($probe -and -not (Get-NextcloudProbeProblem -Probe $probe)) { ConvertFrom-NextcloudStatus -Text "$(Get-DataProperty $probe 'Body')" })
            $row[(Get-Text 'Column.Nextcloud.ServerState')] = $(
                if ($null -eq $probe)                                 { Get-Text 'Value.Nextcloud.Server.State.NotAsked' }
                elseif ($null -eq $status)                            { Get-Text 'Value.Nextcloud.Server.State.Unknown' }
                elseif ($status.Maintenance -or $status.NeedsDbUpgrade) { Get-Text 'Value.Nextcloud.Server.State.Maintenance' }
                else                                                  { Get-Text 'Value.Nextcloud.Server.State.Ready' })
            $row[(Get-Text 'Column.Nextcloud.ServerVersion')] = $(if ($status) { $status.Version })
            $row[(Get-Text 'Column.Nextcloud.Schemes')] = (@((Get-DataCollection $_ 'Schemes') | ForEach-Object { Get-NextcloudSchemeText "$_" }) -join ', ')
            [pscustomobject]$row
        }
    )
}