Private/Kinds/Nextcloud.ps1
|
# The Nextcloud Kind: whether the Nextcloud client is keeping up, read from its own journal. # # The counterpart of the OneDrive Kind for the other sync client a Customer runs. The # Nextcloud client keeps, in every folder it synchronises, a journal: an SQLite file # (.sync_<id>.db) with a row for every file it knows and, apart from those, the files it # could not transfer and has set aside, the conflicts nobody has resolved, and the # transfers that are under way. Observed 2026-10-13 on a workstation with one account # and one folder: 19621 files, 11534 of them only in the cloud, 5682 folders, nothing set # aside and no conflict. # # What is read: # # - nextcloud.cfg, the client's configuration: the version, each account's server, and # each folder with where it lies, whether it is paused and whether it keeps files # only in the cloud. Of a server its name is kept; no user name and no certificate. # - The journal of each folder: the counts, and the files set aside by what the client # wrote as the reason - never by their path. Which file it is, is what a User keeps # there, and a Run reads state and not content. See ADR-0006. # # The client holds its journal locked against every other reader, so it is read as a # file nobody writes to (Private\Sqlite.ps1). That shows the journal as of its last # change on disk, and misses what the client has noted since and not yet written back: # on the machine observed that was ten hours. A Finding says how old what it read is. # The kinds of entry in the journal's list of files, as the client numbers them. $script:NextcloudTypeFile = 0 $script:NextcloudTypeFolder = 2 $script:NextcloudTypePlaceholder = 4 function ConvertFrom-NextcloudConfig { <# .SYNOPSIS The text of nextcloud.cfg as the client's version and its folders. Pure. .DESCRIPTION The file is an ini as Qt writes it. Under [Accounts] a key is a path: "0\url" is the server of account 0, and "0\FoldersWithPlaceholders\1\localPath" is where folder 1 of that account lies. A folder stands under "Folders", "FoldersWithPlaceholders" or "Multifolders", by how it was set up. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Text) $section = '' $version = '' $servers = @{} $folders = [ordered]@{} foreach ($line in ("$Text" -split "`r?`n")) { if ($line -match '^\s*\[(.+)\]\s*$') { $section = $Matches[1]; continue } if ($line -notmatch '^\s*([^=]+?)\s*=\s*(.*?)\s*$') { continue } $name = $Matches[1] $value = $Matches[2].Trim('"') if ($section -eq 'General' -and $name -eq 'clientVersion') { $version = $value; continue } if ($section -ne 'Accounts') { continue } if ($name -match '^(\d+)\\url$') { $server = $value try { $server = ([uri]$value).Host } catch { $server = $value } $servers[$Matches[1]] = "$server" continue } if ($name -match '^(\d+)\\(?:Folders|FoldersWithPlaceholders|Multifolders)\\([^\\]+)\\(localPath|journalPath|paused|virtualFilesMode)$') { $key = '{0}|{1}' -f $Matches[1], $Matches[2] if (-not $folders.Contains($key)) { $folders[$key] = @{ Account = $Matches[1] } } $folders[$key][$Matches[3]] = $value } } [pscustomobject]@{ Version = $version Folders = @($folders.Keys | ForEach-Object { $one = $folders[$_] # Qt writes a path with forward slashes, and a backslash doubled. $path = ("$($one['localPath'])" -replace '\\\\', '\' -replace '/', '\').TrimEnd('\') if (-not $path) { return } [pscustomobject]@{ Account = "$($one['Account'])" Server = "$($servers[$one['Account']])" Path = $path Journal = "$($one['journalPath'])" Paused = ("$($one['paused'])" -eq 'true') Virtual = ("$($one['virtualFilesMode'])" -and "$($one['virtualFilesMode'])" -ne 'off') } }) } } function Read-NextcloudJournal { <# .SYNOPSIS What the journal of one folder says: the counts, and the files set aside by the reason the client wrote. Asks only; reads no path of a file. Never throws. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][string]$Path) $read = [ordered]@{ Read = $false; Problem = ''; Files = $null; Folders = $null; Placeholders = $null Errors = @(); Conflicts = $null; CaseConflicts = $null; Uploads = $null; Downloads = $null } try { $types = @{} foreach ($row in (Read-SqliteFile -Path $Path -Query 'select type, count(*) from metadata group by type')) { $types["$($row[0])"] = [int]$row[1] } $read.Files = [int]$types["$script:NextcloudTypeFile"] + [int]$types["$script:NextcloudTypePlaceholder"] $read.Placeholders = [int]$types["$script:NextcloudTypePlaceholder"] $read.Folders = [int]$types["$script:NextcloudTypeFolder"] $read.Read = $true } catch { $read.Problem = "$($_.Exception.GetBaseException().Message)"; return [pscustomobject]$read } # Each of these by itself: a journal of an older client lacks a table, and that is # no reason to lose the others. try { $read.Errors = @(Read-SqliteFile -Path $Path -Query 'select errorCategory, errorstring, count(*), max(lastTryTime) from blacklist group by errorCategory, errorstring order by 3 desc limit 20' | ForEach-Object { $last = $null $seconds = ConvertTo-Number $_[3] if ($null -ne $seconds -and $seconds -gt 0) { $last = ([datetime]'1970-01-01T00:00:00Z').ToUniversalTime().AddSeconds($seconds).ToLocalTime() } [pscustomobject]@{ Category = ConvertTo-Number $_[0]; Text = "$($_[1])".Trim(); Count = [int]$_[2]; Last = $last } }) } catch { $read.Errors = @() } foreach ($pair in @(@('Conflicts', 'conflicts'), @('CaseConflicts', 'caseconflicts'), @('Uploads', 'uploadinfo'), @('Downloads', 'downloadinfo'))) { try { $read[$pair[0]] = [int]@(Read-SqliteFile -Path $Path -Query ('select count(*) from {0}' -f $pair[1]))[0][0] } catch { $read[$pair[0]] = $null } } [pscustomobject]$read } # 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 Path = $_.Path Exists = $exists Paused = $_.Paused Virtual = $_.Virtual # $null where there is no journal: not a journal that names no error. Journal = $journal JournalWritten = $written } }) $running = $null try { $running = [bool]@(Get-Process -Name 'nextcloud' -ErrorAction SilentlyContinue).Count } catch { $running = $null } [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.Nextcloud' App = "$(Get-Parameter $Parameters 'App' 'Nextcloud')" Configured = $null -ne $config Version = "$(Get-DataProperty $config 'Version')" Folders = $folders Running = $running GatheredAt = Get-Date } } function New-NextcloudSyncFinding { <# .SYNOPSIS Whether one folder is keeping up: the files the client set aside and why, the conflicts nobody resolved, what is under way, and how old the journal is. .DESCRIPTION A WARN where the client set files aside or a conflict is open: both stay until somebody does something. A transfer under way and a folder that is paused are said and are no fault. #> [CmdletBinding()] param([AllowNull()]$Folder, [AllowNull()]$Data, [hashtable]$Parameters) $path = "$(Get-DataProperty $Folder 'Path')" $check = (Get-Text 'Check.Nextcloud.Sync') -f $path if ((Get-DataProperty $Data 'Running') -eq $false) { return New-Finding -Category Apps -Check $check -Severity WARN -Value (Get-Text 'Value.Nextcloud.NotRunning') ` -Meaning (Get-Text 'Meaning.Nextcloud.NotRunning') -Hint (Get-Text 'Hint.Nextcloud.NotRunning') } if ((Get-DataProperty $Folder 'Exists') -eq $false) { return New-Finding -Category Apps -Check $check -Severity WARN -Value (Get-Text 'Value.Nextcloud.FolderMissing') ` -Meaning (Get-Text 'Meaning.Nextcloud.FolderMissing') -Hint (Get-Text 'Hint.Nextcloud.FolderMissing') } $journal = Get-DataProperty $Folder 'Journal' if ($null -eq $journal -or -not [bool](Get-DataProperty $journal 'Read')) { $value = Get-Text 'Value.Nextcloud.NoJournal' $problem = "$(Get-DataProperty $journal 'Problem')" if ($problem -eq 'NoSqlite') { $value = Get-Text 'Value.Nextcloud.NoSqlite' } return New-Finding -Category Apps -Check $check -Severity INFO -Value $value -Hint (Get-Text 'Hint.Nextcloud.NoJournal') } $errors = @((Get-DataCollection $journal 'Errors') | Where-Object { $_ }) $failed = [int](($errors | ForEach-Object { ConvertTo-Number (Get-DataProperty $_ 'Count') } | Measure-Object -Sum).Sum) $conflicts = [int](ConvertTo-Number (Get-DataProperty $journal 'Conflicts')) + [int](ConvertTo-Number (Get-DataProperty $journal 'CaseConflicts')) $moving = [int](ConvertTo-Number (Get-DataProperty $journal 'Uploads')) + [int](ConvertTo-Number (Get-DataProperty $journal 'Downloads')) $paused = [bool](Get-DataProperty $Folder 'Paused') $value = @() if ($failed) { $reasons = @($errors | Select-Object -First 5 | ForEach-Object { $text = "$(Get-DataProperty $_ 'Text')" if (-not $text) { $text = Get-Text 'Value.Nextcloud.NoReason' } # The one category the client keeps apart: the server is full. if ((ConvertTo-Number (Get-DataProperty $_ 'Category')) -eq 1) { $text = (Get-Text 'Value.Nextcloud.ServerFull') -f $text } '{0} {1}x' -f ($text -replace ';', ','), [int](ConvertTo-Number (Get-DataProperty $_ 'Count')) }) $value += (Get-Text 'Value.Nextcloud.Failed') -f $failed $value += (Get-Text 'Value.Nextcloud.Reasons') -f ($reasons -join '; ') } if ($conflicts) { $value += (Get-Text 'Value.Nextcloud.Conflicts') -f $conflicts } if ($moving) { $value += (Get-Text 'Value.Nextcloud.Moving') -f $moving } if ($paused) { $value += Get-Text 'Value.Nextcloud.Paused' } if (-not $failed -and -not $conflicts -and -not $moving -and -not $paused) { $value += Get-Text 'Value.Nextcloud.UpToDate' } $value += (Get-Text 'Value.Nextcloud.Counts') -f [int](ConvertTo-Number (Get-DataProperty $journal 'Files')), [int](ConvertTo-Number (Get-DataProperty $journal 'Folders')), [int](ConvertTo-Number (Get-DataProperty $journal 'Placeholders')) $written = ConvertTo-DataTime (Get-DataProperty $Folder 'JournalWritten') if ($null -ne $written) { $value += (Get-Text 'Value.Nextcloud.JournalAt') -f $written } $reference = @(New-Reference -Section 'Title.Nextcloud.Folders') if ($failed -or $conflicts) { $meaning = @() $hint = @() if ($failed) { $meaning += Get-Text 'Meaning.Nextcloud.Failed'; $hint += Get-Text 'Hint.Nextcloud.Failed' } if ($conflicts) { $meaning += Get-Text 'Meaning.Nextcloud.Conflicts'; $hint += Get-Text 'Hint.Nextcloud.Conflicts' } $newest = @($errors | ForEach-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Last') } | Where-Object { $null -ne $_ } | Sort-Object)[-1] return New-Finding -Category Apps -Check $check -Severity WARN -Value ($value -join ' | ') ` -Meaning ($meaning -join ' | ') -Hint ($hint -join ' | ') -Reference $reference -LastSeen $newest } if ($moving -or $paused) { return New-Finding -Category Apps -Check $check -Severity INFO -Value ($value -join ' | ') ` -Meaning (Get-Text 'Meaning.Nextcloud.Waits') -Hint (Get-Text 'Hint.Nextcloud.Waits') -Reference $reference } New-Finding -Category Apps -Check $check -Severity OK -Value ($value -join ' | ') -Reference $reference } function New-NextcloudSetupFinding { <# .SYNOPSIS How the Nextcloud client is set up for the User of the Run: the servers, the folders, whether files are kept only in the cloud, and the version. #> [CmdletBinding()] param([AllowNull()]$Data) $check = Get-Text 'Check.Nextcloud.Setup' $folders = @((Get-DataCollection $Data 'Folders') | Where-Object { $_ }) if (-not $folders.Count) { return New-Finding -Category Apps -Check $check -Severity INFO -Value (Get-Text 'Value.Nextcloud.NoFolder') -Hint (Get-Text 'Hint.Nextcloud.NoFolder') } $servers = @($folders | ForEach-Object { "$(Get-DataProperty $_ 'Server')" } | Where-Object { $_ } | Sort-Object -Unique) $value = @() if ($servers.Count) { $value += (Get-Text 'Value.Nextcloud.Servers') -f ($servers -join ', ') } $value += (Get-Text 'Value.Nextcloud.FolderList') -f (@($folders | ForEach-Object { $mode = Get-Text 'Value.Nextcloud.Mode.Full' if ([bool](Get-DataProperty $_ 'Virtual')) { $mode = Get-Text 'Value.Nextcloud.Mode.Virtual' } (Get-Text 'Value.Nextcloud.FolderItem') -f "$(Get-DataProperty $_ 'Path')", $mode }) -join '; ') $version = "$(Get-DataProperty $Data 'Version')".Trim() if ($version) { $value += (Get-Text 'Value.Nextcloud.Version') -f $version } New-Finding -Category Apps -Check $check -Severity OK -Value ($value -join ' | ') } function ConvertTo-NextcloudFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{} ) New-NextcloudSetupFinding -Data $Data foreach ($folder in @((Get-DataCollection $Data 'Folders') | Where-Object { $_ })) { New-NextcloudSyncFinding -Folder $folder -Data $Data -Parameters $Parameters } } function ConvertTo-NextcloudSection { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $folders = @((Get-DataCollection $Data 'Folders') | Where-Object { $_ }) if (-not $folders.Count) { return } New-Section -Title (Get-Text 'Title.Nextcloud.Folders') -Help (Get-Text 'Help.Nextcloud.Folders') -Row @( $folders | ForEach-Object { $journal = Get-DataProperty $_ 'Journal' $read = [bool](Get-DataProperty $journal 'Read') $n = { param($Name) if ($read) { ConvertTo-Number (Get-DataProperty $journal $Name) } } $row = [ordered]@{} $row[(Get-Text 'Column.Nextcloud.Folder')] = "$(Get-DataProperty $_ 'Path')" $row[(Get-Text 'Column.Nextcloud.Server')] = "$(Get-DataProperty $_ 'Server')" $row[(Get-Text 'Column.Nextcloud.JournalAt')] = Get-DataProperty $_ 'JournalWritten' $row[(Get-Text 'Column.Nextcloud.Files')] = & $n 'Files' $row[(Get-Text 'Column.Nextcloud.Placeholders')] = & $n 'Placeholders' $row[(Get-Text 'Column.Nextcloud.Folders')] = & $n 'Folders' $row[(Get-Text 'Column.Nextcloud.Failed')] = $(if ($read) { [int](((Get-DataCollection $journal 'Errors') | ForEach-Object { ConvertTo-Number (Get-DataProperty $_ 'Count') } | Measure-Object -Sum).Sum) }) $row[(Get-Text 'Column.Nextcloud.Conflicts')] = $(if ($read) { [int](ConvertTo-Number (Get-DataProperty $journal 'Conflicts')) + [int](ConvertTo-Number (Get-DataProperty $journal 'CaseConflicts')) }) $row[(Get-Text 'Column.Nextcloud.Moving')] = $(if ($read) { [int](ConvertTo-Number (Get-DataProperty $journal 'Uploads')) + [int](ConvertTo-Number (Get-DataProperty $journal 'Downloads')) }) $row[(Get-Text 'Column.Nextcloud.Paused')] = Get-TriStateText ([bool](Get-DataProperty $_ 'Paused')) $row[(Get-Text 'Column.Nextcloud.Virtual')] = Get-TriStateText ([bool](Get-DataProperty $_ 'Virtual')) [pscustomobject]$row } ) $rows = @($folders | ForEach-Object { $path = "$(Get-DataProperty $_ 'Path')" (Get-DataCollection (Get-DataProperty $_ 'Journal') 'Errors') | Where-Object { $_ } | ForEach-Object { $row = [ordered]@{} $row[(Get-Text 'Column.Nextcloud.Folder')] = $path $row[(Get-Text 'Column.Nextcloud.Count')] = [int](ConvertTo-Number (Get-DataProperty $_ 'Count')) $row[(Get-Text 'Column.Nextcloud.Reason')] = "$(Get-DataProperty $_ 'Text')" $row[(Get-Text 'Column.Nextcloud.Last')] = Get-DataProperty $_ 'Last' [pscustomobject]$row } }) if ($rows.Count) { New-Section -Title (Get-Text 'Title.Nextcloud.Errors') -Help (Get-Text 'Help.Nextcloud.Errors') -Row $rows } } |