import type { BackupPhase, BackupStatus, BackupStatusEnvelope, OverallStatus, PhaseStatus, ResticSnapshot, ResticStats, RestoreTestAssertion, RestoreTestStatus, } from './types' const PHASE_ORDER = [ 'postgres_dump', 'forgejo_dump', 'forgejo_db_dump', 'restic_nas', 'restic_b2', 'forget_nas', 'check_nas', 'check_b2', ] as const function isRecord(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v) } function asString(v: unknown): string | null { return typeof v === 'string' ? v : null } function asNumber(v: unknown): number | null { return typeof v === 'number' && Number.isFinite(v) ? v : null } function asPhaseStatus(v: unknown): PhaseStatus { if ( v === 'success' || v === 'skipped' || v === 'degraded' || v === 'failed' || v === 'pending' ) { return v } return 'pending' } function asOverallStatus(v: unknown): OverallStatus { if (v === 'success' || v === 'partial_failure' || v === 'failed') return v return 'unknown' } function parsePhase(name: string, raw: unknown): BackupPhase { if (!isRecord(raw)) { return { name, status: 'pending', exitCode: null, startedAt: null, completedAt: null, error: null, } } return { name, status: asPhaseStatus(raw.status), exitCode: asNumber(raw.exit_code), startedAt: asString(raw.started_at), completedAt: asString(raw.completed_at), error: asString(raw.error), snapshotId: asString(raw.snapshot_id) ?? undefined, filesNew: asNumber(raw.files_new), dataAddedBytes: asNumber(raw.data_added_bytes), outputFile: asString(raw.output_file) ?? undefined, bytes: asNumber(raw.bytes), } } function parseBackupStatus(raw: unknown): BackupStatus | null { if (!isRecord(raw)) return null const phasesRaw = isRecord(raw.phases) ? raw.phases : {} const phases = PHASE_ORDER.map((name) => parsePhase(name, phasesRaw[name])) return { schemaVersion: asNumber(raw.schema_version) ?? 1, overallStatus: asOverallStatus(raw.overall_status), startedAt: asString(raw.started_at) ?? '', completedAt: asString(raw.completed_at) ?? '', durationSeconds: asNumber(raw.duration_seconds) ?? 0, host: asString(raw.host) ?? '', phases, } } function parseRestoreTestAssertion(raw: unknown): RestoreTestAssertion | null { if (!isRecord(raw)) return null const status = raw.status if (status !== 'ok' && status !== 'empty' && status !== 'missing') return null return { path: asString(raw.path) ?? '', status, bytes: asNumber(raw.bytes) ?? 0, } } function parseRestoreTestStatus(raw: unknown): RestoreTestStatus | null { if (!isRecord(raw)) return null const assertionsRaw = Array.isArray(raw.assertions) ? raw.assertions : [] const assertions: RestoreTestAssertion[] = [] for (const a of assertionsRaw) { const parsed = parseRestoreTestAssertion(a) if (parsed) assertions.push(parsed) } return { schemaVersion: asNumber(raw.schema_version) ?? 1, overallStatus: asOverallStatus(raw.overall_status), startedAt: asString(raw.started_at) ?? '', completedAt: asString(raw.completed_at) ?? '', durationSeconds: asNumber(raw.duration_seconds) ?? 0, repo: asString(raw.repo) ?? '', snapshotId: asString(raw.snapshot_id), restoreExitCode: asNumber(raw.restore_exit_code), target: asString(raw.target) ?? undefined, assertions, error: asString(raw.error) ?? undefined, } } export function parseStatusEnvelope(output: string): BackupStatusEnvelope { try { const trimmed = output.trim() if (!trimmed) return { lastRun: null, lastRestoreTest: null } const parsed: unknown = JSON.parse(trimmed) if (!isRecord(parsed)) return { lastRun: null, lastRestoreTest: null } return { lastRun: parseBackupStatus(parsed.last_run), lastRestoreTest: parseRestoreTestStatus(parsed.last_restore_test), } } catch { return { lastRun: null, lastRestoreTest: null } } } export function parseResticSnapshots(output: string, repo: 'nas' | 'b2'): ResticSnapshot[] { try { const trimmed = output.trim() if (!trimmed) return [] const parsed: unknown = JSON.parse(trimmed) if (!Array.isArray(parsed)) return [] const result: ResticSnapshot[] = [] for (const s of parsed) { if (!isRecord(s)) continue const id = asString(s.id) if (!id) continue const shortId = asString(s.short_id) ?? id.slice(0, 8) const time = asString(s.time) ?? '' const hostname = asString(s.hostname) ?? '' const tags = Array.isArray(s.tags) ? s.tags.filter((t): t is string => typeof t === 'string') : [] const paths = Array.isArray(s.paths) ? s.paths.filter((p): p is string => typeof p === 'string') : [] const summary = isRecord(s.summary) ? (s.summary as ResticSnapshot['summary']) : null result.push({ id, shortId, time, hostname, tags, paths, repo, summary }) } return result } catch { return [] } } export function parseResticStats(output: string, repo: 'nas' | 'b2'): ResticStats | null { try { const trimmed = output.trim() if (!trimmed) return null const parsed: unknown = JSON.parse(trimmed) if (!isRecord(parsed)) return null return { repo, snapshotsCount: asNumber(parsed.snapshots_count) ?? 0, restoreSizeBytes: asNumber(parsed.restore_size_bytes), restoreSizeFiles: asNumber(parsed.restore_size_files), rawDataBytes: asNumber(parsed.raw_data_bytes), rawBlobCount: asNumber(parsed.raw_blob_count), dedupRatio: asNumber(parsed.dedup_ratio), } } catch { return null } }