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