paddel setup new verifier
This commit is contained in:
@@ -1,110 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class SyncJob {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var sourcePath: String
|
||||
var destinationPath: String
|
||||
var isRemote: Bool
|
||||
var remoteHost: String
|
||||
var remoteUser: String
|
||||
var remotePort: Int
|
||||
var sshKeyPath: String
|
||||
var archive: Bool
|
||||
var compress: Bool
|
||||
var deleteExtraneous: Bool
|
||||
var preservePermissions: Bool
|
||||
var preserveExtendedAttributes: Bool
|
||||
var showProgress: Bool
|
||||
var exclusionsText: String
|
||||
var additionalArguments: String
|
||||
var isEnabled: Bool
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var lastRunAt: Date?
|
||||
var lastExitCode: Int?
|
||||
var scheduleEnabled: Bool = false
|
||||
var scheduleKindRaw: String = SyncScheduleKind.manual.rawValue
|
||||
var scheduleStartAt: Date = Date().addingTimeInterval(3600)
|
||||
var scheduleIntervalMinutes: Int = 30
|
||||
var nextRunAt: Date? = nil
|
||||
var watchEnabled: Bool = false
|
||||
var watchPath: String = ""
|
||||
var watchDebounceSeconds: Int = 3
|
||||
var lastWatchEventAt: Date? = nil
|
||||
var allowUnattendedDelete: Bool = false
|
||||
var retryCount: Int = 2
|
||||
var retryDelaySeconds: Int = 10
|
||||
var notifyOnFailure: Bool = true
|
||||
var notifyOnSuccess: Bool = false
|
||||
var requireExpectedVolume: Bool = false
|
||||
var expectedVolumeIdentifier: String = ""
|
||||
var minimumFreeSpaceMB: Int = 512
|
||||
|
||||
init(
|
||||
name: String = "New Sync Job",
|
||||
sourcePath: String = "",
|
||||
destinationPath: String = ""
|
||||
) {
|
||||
self.id = UUID()
|
||||
self.name = name
|
||||
self.sourcePath = sourcePath
|
||||
self.destinationPath = destinationPath
|
||||
self.isRemote = false
|
||||
self.remoteHost = ""
|
||||
self.remoteUser = ""
|
||||
self.remotePort = 22
|
||||
self.sshKeyPath = ""
|
||||
self.archive = true
|
||||
self.compress = false
|
||||
self.deleteExtraneous = false
|
||||
self.preservePermissions = true
|
||||
self.preserveExtendedAttributes = true
|
||||
self.showProgress = true
|
||||
self.exclusionsText = ""
|
||||
self.additionalArguments = ""
|
||||
self.isEnabled = true
|
||||
self.createdAt = Date()
|
||||
self.updatedAt = Date()
|
||||
self.lastRunAt = nil
|
||||
self.lastExitCode = nil
|
||||
self.scheduleEnabled = false
|
||||
self.scheduleKindRaw = SyncScheduleKind.manual.rawValue
|
||||
self.scheduleStartAt = Date().addingTimeInterval(3600)
|
||||
self.scheduleIntervalMinutes = 30
|
||||
self.nextRunAt = nil
|
||||
self.watchEnabled = false
|
||||
self.watchPath = ""
|
||||
self.watchDebounceSeconds = 3
|
||||
self.lastWatchEventAt = nil
|
||||
self.allowUnattendedDelete = false
|
||||
self.retryCount = 2
|
||||
self.retryDelaySeconds = 10
|
||||
self.notifyOnFailure = true
|
||||
self.notifyOnSuccess = false
|
||||
self.requireExpectedVolume = false
|
||||
self.expectedVolumeIdentifier = ""
|
||||
self.minimumFreeSpaceMB = 512
|
||||
}
|
||||
|
||||
var exclusions: [String] {
|
||||
exclusionsText
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
var expectedVolumeDisplay: String {
|
||||
expectedVolumeIdentifier.isEmpty ? "Not pinned" : expectedVolumeIdentifier
|
||||
}
|
||||
|
||||
var destinationDisplay: String {
|
||||
if isRemote {
|
||||
let userPrefix = remoteUser.isEmpty ? "" : "\(remoteUser)@"
|
||||
return "\(userPrefix)\(remoteHost):\(destinationPath)"
|
||||
}
|
||||
return destinationPath
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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 Studio 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 Studio 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
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum SyncScheduleKind: String, CaseIterable, Identifiable, Codable {
|
||||
case manual
|
||||
case once
|
||||
case hourly
|
||||
case daily
|
||||
case weekly
|
||||
case monthly
|
||||
case custom
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .manual: return "Manual only"
|
||||
case .once: return "Once"
|
||||
case .hourly: return "Hourly"
|
||||
case .daily: return "Daily"
|
||||
case .weekly: return "Weekly"
|
||||
case .monthly: return "Monthly"
|
||||
case .custom: return "Custom interval"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .manual: return "hand.tap"
|
||||
case .once: return "calendar.badge.clock"
|
||||
case .hourly: return "clock.arrow.circlepath"
|
||||
case .daily: return "sun.max"
|
||||
case .weekly: return "calendar"
|
||||
case .monthly: return "calendar.circle"
|
||||
case .custom: return "timer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SyncRunTrigger: String, Codable {
|
||||
case manual
|
||||
case scheduled
|
||||
case watched
|
||||
case syncAll
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .manual: return "Manual"
|
||||
case .scheduled: return "Scheduled"
|
||||
case .watched: return "File change"
|
||||
case .syncAll: return "Sync All"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .manual: return "play.fill"
|
||||
case .scheduled: return "calendar.badge.clock"
|
||||
case .watched: return "eye.fill"
|
||||
case .syncAll: return "arrow.triangle.2.circlepath"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SyncJob {
|
||||
var scheduleKind: SyncScheduleKind {
|
||||
get { SyncScheduleKind(rawValue: scheduleKindRaw) ?? .manual }
|
||||
set { scheduleKindRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var effectiveWatchPath: String {
|
||||
let trimmed = watchPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? sourcePath : trimmed
|
||||
}
|
||||
|
||||
var scheduleDescription: String {
|
||||
guard scheduleEnabled, scheduleKind != .manual else { return "Manual" }
|
||||
let time = scheduleStartAt.formatted(date: .omitted, time: .shortened)
|
||||
switch scheduleKind {
|
||||
case .manual: return "Manual"
|
||||
case .once: return "Once · \(scheduleStartAt.formatted(date: .abbreviated, time: .shortened))"
|
||||
case .hourly: return "Every hour"
|
||||
case .daily: return "Daily · \(time)"
|
||||
case .weekly:
|
||||
return "Weekly · \(scheduleStartAt.formatted(.dateTime.weekday(.abbreviated))) · \(time)"
|
||||
case .monthly:
|
||||
return "Monthly · day \(Calendar.current.component(.day, from: scheduleStartAt)) · \(time)"
|
||||
case .custom:
|
||||
return "Every \(max(1, scheduleIntervalMinutes)) min"
|
||||
}
|
||||
}
|
||||
|
||||
func calculateNextRun(after date: Date = Date()) -> Date? {
|
||||
guard scheduleEnabled, scheduleKind != .manual else { return nil }
|
||||
let calendar = Calendar.current
|
||||
|
||||
switch scheduleKind {
|
||||
case .manual:
|
||||
return nil
|
||||
case .once:
|
||||
return scheduleStartAt > date ? scheduleStartAt : nil
|
||||
case .hourly:
|
||||
return nextInterval(after: date, seconds: 3600)
|
||||
case .custom:
|
||||
return nextInterval(after: date, seconds: TimeInterval(max(1, scheduleIntervalMinutes) * 60))
|
||||
case .daily:
|
||||
let time = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt)
|
||||
return calendar.nextDate(after: date, matching: time, matchingPolicy: .nextTime)
|
||||
case .weekly:
|
||||
var components = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt)
|
||||
components.weekday = calendar.component(.weekday, from: scheduleStartAt)
|
||||
return calendar.nextDate(after: date, matching: components, matchingPolicy: .nextTime)
|
||||
case .monthly:
|
||||
var components = calendar.dateComponents([.hour, .minute, .second], from: scheduleStartAt)
|
||||
components.day = calendar.component(.day, from: scheduleStartAt)
|
||||
return calendar.nextDate(after: date, matching: components, matchingPolicy: .nextTime)
|
||||
}
|
||||
}
|
||||
|
||||
func occurrences(from start: Date, through end: Date, limit: Int = 400) -> [Date] {
|
||||
guard scheduleEnabled, scheduleKind != .manual, start < end else { return [] }
|
||||
var output: [Date] = []
|
||||
var cursor = start.addingTimeInterval(-1)
|
||||
|
||||
if scheduleKind == .once {
|
||||
return (scheduleStartAt >= start && scheduleStartAt < end) ? [scheduleStartAt] : []
|
||||
}
|
||||
|
||||
while output.count < limit, let next = calculateNextRun(after: cursor), next < end {
|
||||
if next >= start { output.append(next) }
|
||||
cursor = next.addingTimeInterval(0.5)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private func nextInterval(after date: Date, seconds: TimeInterval) -> Date? {
|
||||
guard seconds > 0 else { return nil }
|
||||
if scheduleStartAt > date { return scheduleStartAt }
|
||||
let elapsed = date.timeIntervalSince(scheduleStartAt)
|
||||
let steps = floor(elapsed / seconds) + 1
|
||||
return scheduleStartAt.addingTimeInterval(steps * seconds)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user