import Foundation import SwiftData @Model final class SyncRun { var id: UUID var jobID: UUID var jobName: String var startedAt: Date var finishedAt: Date? var command: String var output: String var errorOutput: String var exitCode: Int? var wasDryRun: Bool var wasCancelled: Bool var triggerRaw: String = SyncRunTrigger.manual.rawValue init(job: SyncJob, command: String, wasDryRun: Bool, trigger: SyncRunTrigger = .manual) { self.id = UUID() self.jobID = job.id self.jobName = job.name self.startedAt = Date() self.command = command self.output = "" self.errorOutput = "" self.exitCode = nil self.wasDryRun = wasDryRun self.wasCancelled = false self.triggerRaw = trigger.rawValue } var trigger: SyncRunTrigger { SyncRunTrigger(rawValue: triggerRaw) ?? .manual } var succeeded: Bool { exitCode == 0 && !wasCancelled } var isRunning: Bool { finishedAt == nil } var duration: TimeInterval? { guard let finishedAt else { return nil } return finishedAt.timeIntervalSince(startedAt) } var summary: RsyncSummary { RsyncSummary.parse(output + "\n" + errorOutput) } static func sanitizedLog(_ value: String, maxBytes: Int = 2_000_000) -> String { let stripped = stripANSI(value) let redacted = stripped .replacingOccurrences(of: #"(?i)--password-file(?:=|\s+)[^\s]+"#, with: "--password-file=[REDACTED]", options: .regularExpression) .replacingOccurrences(of: #"(?i)(password|passwd|token|secret)=([^\s]+)"#, with: "$1=[REDACTED]", options: .regularExpression) let data = Data(redacted.utf8) guard data.count > maxBytes else { return redacted } let prefix = data.prefix(maxBytes) return String(decoding: prefix, as: UTF8.self) + "\n\n[Log truncated by Rsync Manager for safety and storage limits.]\n" } private static func stripANSI(_ value: String) -> String { value.replacingOccurrences( of: #"\u001B\[[0-9;?]*[ -/]*[@-~]"#, with: "", options: .regularExpression ) } var exportText: String { var text = "Rsync Manager Run Log\n" text += "Job: \(jobName)\n" text += "Started: \(startedAt.formatted(date: .complete, time: .standard))\n" if let finishedAt { text += "Finished: \(finishedAt.formatted(date: .complete, time: .standard))\n" } text += "Trigger: \(trigger.title)\n" text += "Dry run: \(wasDryRun ? "Yes" : "No")\n" text += "\nCommand\n-------\n$ \(command)\n\nOutput\n------\n" if !output.isEmpty { text += output } if !errorOutput.isEmpty { if !text.hasSuffix("\n") { text += "\n" } text += "\n--- stderr ---\n\(errorOutput)" } if let exitCode { if !text.hasSuffix("\n") { text += "\n" } text += "\nExit code: \(exitCode)\n" } return text } } struct RsyncSummary: Equatable { var numberOfFiles: String? var numberOfFilesTransferred: String? var totalFileSize: String? var totalTransferredFileSize: String? var unmatchedData: String? var matchedData: String? var fileListSize: String? var fileListGenerationTime: String? var fileListTransferTime: String? var totalSent: String? var totalReceived: String? var transferRate: String? var totalSizeAndSpeedup: String? var hasStatistics: Bool { [ numberOfFiles, numberOfFilesTransferred, totalFileSize, totalTransferredFileSize, totalSent, totalReceived ].contains { $0 != nil } } static func parse(_ text: String) -> RsyncSummary { var result = RsyncSummary() for rawLine in text.components(separatedBy: .newlines) { let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) guard !line.isEmpty else { continue } func value(after prefix: String) -> String? { guard line.hasPrefix(prefix) else { return nil } return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) } if let value = value(after: "Number of files:") { result.numberOfFiles = value; continue } if let value = value(after: "Number of files transferred:") { result.numberOfFilesTransferred = value; continue } if let value = value(after: "Total file size:") { result.totalFileSize = value; continue } if let value = value(after: "Total transferred file size:") { result.totalTransferredFileSize = value; continue } if let value = value(after: "Unmatched data:") { result.unmatchedData = value; continue } if let value = value(after: "Matched data:") { result.matchedData = value; continue } if let value = value(after: "File list size:") { result.fileListSize = value; continue } if let value = value(after: "File list generation time:") { result.fileListGenerationTime = value; continue } if let value = value(after: "File list transfer time:") { result.fileListTransferTime = value; continue } if let value = value(after: "Total sent:") { result.totalSent = value; continue } if let value = value(after: "Total received:") { result.totalReceived = value; continue } if line.hasPrefix("sent "), line.contains(" bytes/sec") { result.transferRate = line continue } if line.hasPrefix("total size is "), line.contains("speedup is") { result.totalSizeAndSpeedup = line } } return result } }