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.
#
# 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

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 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]@{}
    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+)\\url$') {
            $account = $Matches[1]
            $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
        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'
    Folders    = 'List'
    Running    = 'Value'
    GatheredAt = 'Answered'
}

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
            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 }

    [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 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 '^Server replied "(?<status>\d{3})(?: (?<reason>[^"]*))?" to "(?<request>[^"]*)"') {
        $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 }
        $reason = Format-NextcloudReason -Text "$(Get-DataProperty $failure 'Text')" -Category (Get-DataProperty $failure 'Category')
        New-TimedEvent -Time $at -Lane Server -Mark Failed -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
    }
    # And the server of each account, for the Network Map: once, however many folders.
    $urls = @((Get-DataCollection $Data 'Folders') | Where-Object { $_ } | ForEach-Object { "$(Get-DataProperty $_ 'Url')" } | Where-Object { $_ } | Select-Object -Unique)
    Select-UniqueLink -Link @($urls | ForEach-Object { New-ServiceLink -Url $_ })
}

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 }
}