Commit 9384a668bf
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -354,7 +354,7 @@ | |||
| 354 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 354 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 355 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 355 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 356 | CODE_SIGN_STYLE = Automatic; | 356 | CODE_SIGN_STYLE = Automatic; |
| 357 | CURRENT_PROJECT_VERSION = 20; | 357 | CURRENT_PROJECT_VERSION = 21; |
| 358 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 358 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 359 | ENABLE_PREVIEWS = YES; | 359 | ENABLE_PREVIEWS = YES; |
| 360 | GENERATE_INFOPLIST_FILE = YES; | 360 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -371,7 +371,7 @@ | |||
| 371 | "$(inherited)", | 371 | "$(inherited)", |
| 372 | "@executable_path/Frameworks", | 372 | "@executable_path/Frameworks", |
| 373 | ); | 373 | ); |
| 374 | MARKETING_VERSION = 2.7.0; | 374 | MARKETING_VERSION = 2.8.0; |
| 375 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 375 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 376 | PRODUCT_NAME = "$(TARGET_NAME)"; | 376 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 377 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 377 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -390,7 +390,7 @@ | |||
| 390 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 390 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 391 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 391 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 392 | CODE_SIGN_STYLE = Automatic; | 392 | CODE_SIGN_STYLE = Automatic; |
| 393 | CURRENT_PROJECT_VERSION = 20; | 393 | CURRENT_PROJECT_VERSION = 21; |
| 394 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 394 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 395 | ENABLE_PREVIEWS = YES; | 395 | ENABLE_PREVIEWS = YES; |
| 396 | GENERATE_INFOPLIST_FILE = YES; | 396 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -407,7 +407,7 @@ | |||
| 407 | "$(inherited)", | 407 | "$(inherited)", |
| 408 | "@executable_path/Frameworks", | 408 | "@executable_path/Frameworks", |
| 409 | ); | 409 | ); |
| 410 | MARKETING_VERSION = 2.7.0; | 410 | MARKETING_VERSION = 2.8.0; |
| 411 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 411 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 412 | PRODUCT_NAME = "$(TARGET_NAME)"; | 412 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 413 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 413 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/AppVersion.swift +1 −1
| @@ -2,6 +2,6 @@ import Foundation | |||
| 2 | 2 | ||
| 3 | enum AppVersion { | 3 | enum AppVersion { |
| 4 | static var current: String { | 4 | static var current: String { |
| 5 | "2.7.0" | 5 | "2.8.0" |
| 6 | } | 6 | } |
| 7 | } | 7 | } |
DomainDig/ContentView.swift +112
| @@ -18,6 +18,10 @@ enum ResultSection: String, Hashable { | |||
| 18 | case subdomains | 18 | case subdomains |
| 19 | } | 19 | } |
| 20 | 20 | ||
| 21 | private struct WorkflowNavigationTarget: Hashable { | ||
| 22 | let workflowID: UUID | ||
| 23 | } | ||
| 24 | |||
| 21 | struct ContentView: View { | 25 | struct ContentView: View { |
| 22 | @Environment(\.appDensity) private var appDensity | 26 | @Environment(\.appDensity) private var appDensity |
| 23 | @State private var viewModel = DomainViewModel() | 27 | @State private var viewModel = DomainViewModel() |
| @@ -30,6 +34,8 @@ struct ContentView: View { | |||
| 30 | @State private var showTrackLimitAlert = false | 34 | @State private var showTrackLimitAlert = false |
| 31 | @State private var inputMode: LookupInputMode = .single | 35 | @State private var inputMode: LookupInputMode = .single |
| 32 | @State private var collapsedSections: Set<ResultSection> = [.network] | 36 | @State private var collapsedSections: Set<ResultSection> = [.network] |
| 37 | @State private var showingCurrentDomainWorkflowSheet = false | ||
| 38 | @State private var showingBatchWorkflowSheet = false | ||
| 33 | 39 | ||
| 34 | var body: some View { | 40 | var body: some View { |
| 35 | NavigationStack(path: $navigationPath) { | 41 | NavigationStack(path: $navigationPath) { |
| @@ -68,6 +74,7 @@ struct ContentView: View { | |||
| 68 | confidence: viewModel.currentSnapshot.availabilityConfidence, | 74 | confidence: viewModel.currentSnapshot.availabilityConfidence, |
| 69 | snapshotNote: viewModel.currentSnapshot.note, | 75 | snapshotNote: viewModel.currentSnapshot.note, |
| 70 | trackedDomain: viewModel.currentTrackedDomain, | 76 | trackedDomain: viewModel.currentTrackedDomain, |
| 77 | workflows: viewModel.currentDomainWorkflows, | ||
| 71 | trackingLimitMessage: viewModel.trackingLimitMessage, | 78 | trackingLimitMessage: viewModel.trackingLimitMessage, |
| 72 | onTrack: { | 79 | onTrack: { |
| 73 | if !viewModel.trackCurrentDomain() { | 80 | if !viewModel.trackCurrentDomain() { |
| @@ -82,6 +89,15 @@ struct ContentView: View { | |||
| 82 | guard let trackedDomain = viewModel.currentTrackedDomain else { return } | 89 | guard let trackedDomain = viewModel.currentTrackedDomain else { return } |
| 83 | trackingNoteDraft = trackedDomain.note ?? "" | 90 | trackingNoteDraft = trackedDomain.note ?? "" |
| 84 | editingTrackedDomain = trackedDomain | 91 | editingTrackedDomain = trackedDomain |
| 92 | }, | ||
| 93 | onAddToWorkflow: { | ||
| 94 | showingCurrentDomainWorkflowSheet = true | ||
| 95 | }, | ||
| 96 | onOpenWorkflow: { workflow in | ||
| 97 | navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id)) | ||
| 98 | }, | ||
| 99 | onRunWorkflow: { workflow in | ||
| 100 | viewModel.rerunCurrentDomain(in: workflow) | ||
| 85 | } | 101 | } |
| 86 | ) | 102 | ) |
| 87 | .padding(.top, appDensity.metrics.sectionSpacing) | 103 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -224,6 +240,12 @@ struct ContentView: View { | |||
| 224 | Label("Saved Domains", systemImage: "bookmark") | 240 | Label("Saved Domains", systemImage: "bookmark") |
| 225 | } | 241 | } |
| 226 | 242 | ||
| 243 | NavigationLink { | ||
| 244 | WorkflowsView(viewModel: viewModel) | ||
| 245 | } label: { | ||
| 246 | Label("Workflows", systemImage: "square.stack.3d.down.right") | ||
| 247 | } | ||
| 248 | |||
| 227 | NavigationLink { | 249 | NavigationLink { |
| 228 | SettingsView(viewModel: viewModel) | 250 | SettingsView(viewModel: viewModel) |
| 229 | } label: { | 251 | } label: { |
| @@ -235,6 +257,9 @@ struct ContentView: View { | |||
| 235 | } | 257 | } |
| 236 | } | 258 | } |
| 237 | } | 259 | } |
| 260 | .navigationDestination(for: WorkflowNavigationTarget.self) { target in | ||
| 261 | WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID) | ||
| 262 | } | ||
| 238 | } | 263 | } |
| 239 | .onAppear { | 264 | .onAppear { |
| 240 | domainFieldFocused = true | 265 | domainFieldFocused = true |
| @@ -277,6 +302,20 @@ struct ContentView: View { | |||
| 277 | } | 302 | } |
| 278 | } | 303 | } |
| 279 | } | 304 | } |
| 305 | .sheet(isPresented: $showingCurrentDomainWorkflowSheet) { | ||
| 306 | WorkflowBulkAddSheet( | ||
| 307 | viewModel: viewModel, | ||
| 308 | title: "Add Domain to Workflow", | ||
| 309 | availableDomains: [viewModel.searchedDomain] | ||
| 310 | ) | ||
| 311 | } | ||
| 312 | .sheet(isPresented: $showingBatchWorkflowSheet) { | ||
| 313 | WorkflowBulkAddSheet( | ||
| 314 | viewModel: viewModel, | ||
| 315 | title: "Add Batch Domains", | ||
| 316 | availableDomains: viewModel.batchResults.map(\.domain) | ||
| 317 | ) | ||
| 318 | } | ||
| 280 | } | 319 | } |
| 281 | 320 | ||
| 282 | private var inputSection: some View { | 321 | private var inputSection: some View { |
| @@ -353,6 +392,30 @@ struct ContentView: View { | |||
| 353 | HStack { | 392 | HStack { |
| 354 | Spacer() | 393 | Spacer() |
| 355 | if viewModel.resultsLoaded { | 394 | if viewModel.resultsLoaded { |
| 395 | Menu { | ||
| 396 | if !viewModel.isCurrentDomainTracked { | ||
| 397 | Button("Track this domain") { | ||
| 398 | if !viewModel.trackCurrentDomain() { | ||
| 399 | showTrackLimitAlert = true | ||
| 400 | } | ||
| 401 | } | ||
| 402 | } | ||
| 403 | Button("Add to workflow") { | ||
| 404 | showingCurrentDomainWorkflowSheet = true | ||
| 405 | } | ||
| 406 | Button("Copy report JSON") { | ||
| 407 | guard let json = viewModel.exportJSONString() else { return } | ||
| 408 | AppClipboard.copy(json) | ||
| 409 | AppHaptics.copy() | ||
| 410 | } | ||
| 411 | Button("Export report") { | ||
| 412 | shareSingleResults(format: .json) | ||
| 413 | } | ||
| 414 | } label: { | ||
| 415 | Image(systemName: "bolt.circle") | ||
| 416 | .font(appDensity.font(.body, design: .default)) | ||
| 417 | .foregroundStyle(.secondary) | ||
| 418 | } | ||
| 356 | Button { | 419 | Button { |
| 357 | viewModel.toggleSavedDomain() | 420 | viewModel.toggleSavedDomain() |
| 358 | } label: { | 421 | } label: { |
| @@ -392,6 +455,10 @@ struct ContentView: View { | |||
| 392 | } | 455 | } |
| 393 | if !viewModel.currentBatchResultEntries.isEmpty { | 456 | if !viewModel.currentBatchResultEntries.isEmpty { |
| 394 | Menu { | 457 | Menu { |
| 458 | Button("Add to Workflow") { | ||
| 459 | showingBatchWorkflowSheet = true | ||
| 460 | } | ||
| 461 | Divider() | ||
| 395 | Button("Export Batch TXT") { | 462 | Button("Export Batch TXT") { |
| 396 | shareBatchResults(format: .text) | 463 | shareBatchResults(format: .text) |
| 397 | } | 464 | } |
| @@ -982,10 +1049,14 @@ struct DomainSectionView: View { | |||
| 982 | let confidence: ConfidenceLevel? | 1049 | let confidence: ConfidenceLevel? |
| 983 | let snapshotNote: String? | 1050 | let snapshotNote: String? |
| 984 | let trackedDomain: TrackedDomain? | 1051 | let trackedDomain: TrackedDomain? |
| 1052 | let workflows: [DomainWorkflow] | ||
| 985 | let trackingLimitMessage: String? | 1053 | let trackingLimitMessage: String? |
| 986 | let onTrack: () -> Void | 1054 | let onTrack: () -> Void |
| 987 | let onTogglePinned: () -> Void | 1055 | let onTogglePinned: () -> Void |
| 988 | let onEditNote: (() -> Void)? | 1056 | let onEditNote: (() -> Void)? |
| 1057 | let onAddToWorkflow: (() -> Void)? | ||
| 1058 | let onOpenWorkflow: ((DomainWorkflow) -> Void)? | ||
| 1059 | let onRunWorkflow: ((DomainWorkflow) -> Void)? | ||
| 989 | 1060 | ||
| 990 | var body: some View { | 1061 | var body: some View { |
| 991 | CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) { | 1062 | CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) { |
| @@ -1032,6 +1103,47 @@ struct DomainSectionView: View { | |||
| 1032 | MessageRowView(text: trackingLimitMessage, isError: false) | 1103 | MessageRowView(text: trackingLimitMessage, isError: false) |
| 1033 | .padding(.top, 4) | 1104 | .padding(.top, 4) |
| 1034 | } | 1105 | } |
| 1106 | if let onAddToWorkflow { | ||
| 1107 | Button { | ||
| 1108 | onAddToWorkflow() | ||
| 1109 | } label: { | ||
| 1110 | Label("Add to workflow", systemImage: "plus.rectangle.on.folder") | ||
| 1111 | .font(appDensity.font(.caption)) | ||
| 1112 | } | ||
| 1113 | .buttonStyle(.bordered) | ||
| 1114 | .padding(.top, 4) | ||
| 1115 | } | ||
| 1116 | if !workflows.isEmpty { | ||
| 1117 | VStack(alignment: .leading, spacing: 8) { | ||
| 1118 | Text("Part of workflow") | ||
| 1119 | .font(appDensity.font(.caption)) | ||
| 1120 | .foregroundStyle(.secondary) | ||
| 1121 | |||
| 1122 | ForEach(workflows) { workflow in | ||
| 1123 | HStack { | ||
| 1124 | Text(workflow.name) | ||
| 1125 | .font(appDensity.font(.caption)) | ||
| 1126 | .foregroundStyle(.primary) | ||
| 1127 | Spacer() | ||
| 1128 | if let onOpenWorkflow { | ||
| 1129 | Button("Open") { | ||
| 1130 | onOpenWorkflow(workflow) | ||
| 1131 | } | ||
| 1132 | .buttonStyle(.bordered) | ||
| 1133 | .font(appDensity.font(.caption2)) | ||
| 1134 | } | ||
| 1135 | if let onRunWorkflow { | ||
| 1136 | Button("Run") { | ||
| 1137 | onRunWorkflow(workflow) | ||
| 1138 | } | ||
| 1139 | .buttonStyle(.bordered) | ||
| 1140 | .font(appDensity.font(.caption2)) | ||
| 1141 | } | ||
| 1142 | } | ||
| 1143 | } | ||
| 1144 | } | ||
| 1145 | .padding(.top, 4) | ||
| 1146 | } | ||
| 1035 | if availabilityLoading { | 1147 | if availabilityLoading { |
| 1036 | ProgressView("Checking availability…") | 1148 | ProgressView("Checking availability…") |
| 1037 | .appLoadingStyle() | 1149 | .appLoadingStyle() |
DomainDig/DomainViewModel.swift +202 −5
| @@ -175,6 +175,7 @@ final class DomainViewModel { | |||
| 175 | private(set) var batchTotalCount = 0 | 175 | private(set) var batchTotalCount = 0 |
| 176 | private(set) var batchLookupRunning = false | 176 | private(set) var batchLookupRunning = false |
| 177 | var latestBatchSweepSummary: BatchSweepSummary? | 177 | var latestBatchSweepSummary: BatchSweepSummary? |
| 178 | var latestWorkflowRunSummary: WorkflowRunSummary? | ||
| 178 | private(set) var notificationsAuthorized = false | 179 | private(set) var notificationsAuthorized = false |
| 179 | 180 | ||
| 180 | private var lookupTask: Task<Void, Never>? | 181 | private var lookupTask: Task<Void, Never>? |
| @@ -184,6 +185,8 @@ final class DomainViewModel { | |||
| 184 | private var lookupStartedAt: Date? | 185 | private var lookupStartedAt: Date? |
| 185 | private var activeBatchDomains: [String] = [] | 186 | private var activeBatchDomains: [String] = [] |
| 186 | private var lastBatchStartedAt: Date? | 187 | private var lastBatchStartedAt: Date? |
| 188 | private var activeWorkflowRunID: UUID? | ||
| 189 | private var activeWorkflowRunName: String? | ||
| 187 | private let reportBuilder = DomainReportBuilder() | 190 | private let reportBuilder = DomainReportBuilder() |
| 188 | private let inspectionService = DomainInspectionService() | 191 | private let inspectionService = DomainInspectionService() |
| 189 | private(set) var currentResultSource: LookupResultSource = .live | 192 | private(set) var currentResultSource: LookupResultSource = .live |
| @@ -206,6 +209,8 @@ final class DomainViewModel { | |||
| 206 | private static let historyKey = "lookupHistory" | 209 | private static let historyKey = "lookupHistory" |
| 207 | private static let maxHistory = 250 | 210 | private static let maxHistory = 250 |
| 208 | var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries() | 211 | var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries() |
| 212 | private static let workflowsKey = "domainWorkflows" | ||
| 213 | var workflows: [DomainWorkflow] = DomainViewModel.loadWorkflows() | ||
| 209 | var historySearchText = "" | 214 | var historySearchText = "" |
| 210 | var historyDateFilter: HistoryDateFilter = .all | 215 | var historyDateFilter: HistoryDateFilter = .all |
| 211 | var historyChangeFilter: ChangeFilterOption = .all | 216 | var historyChangeFilter: ChangeFilterOption = .all |
| @@ -327,6 +332,9 @@ final class DomainViewModel { | |||
| 327 | 332 | ||
| 328 | var batchProgressLabel: String { | 333 | var batchProgressLabel: String { |
| 329 | guard batchTotalCount > 0 else { return "No active batch" } | 334 | guard batchTotalCount > 0 else { return "No active batch" } |
| 335 | if !batchLookupRunning, batchCompletedCount >= batchTotalCount { | ||
| 336 | return "\(batchCompletedCount)/\(batchTotalCount) • Complete" | ||
| 337 | } | ||
| 330 | let domainLabel = activeBatchDomains.first ?? batchCurrentDomain ?? "Preparing" | 338 | let domainLabel = activeBatchDomains.first ?? batchCurrentDomain ?? "Preparing" |
| 331 | return "\(batchCompletedCount)/\(batchTotalCount) • \(domainLabel)" | 339 | return "\(batchCompletedCount)/\(batchTotalCount) • \(domainLabel)" |
| 332 | } | 340 | } |
| @@ -343,6 +351,11 @@ final class DomainViewModel { | |||
| 343 | return trackedDomain(for: searchedDomain) | 351 | return trackedDomain(for: searchedDomain) |
| 344 | } | 352 | } |
| 345 | 353 | ||
| 354 | var currentDomainWorkflows: [DomainWorkflow] { | ||
| 355 | guard !searchedDomain.isEmpty else { return [] } | ||
| 356 | return workflowsContaining(domain: searchedDomain) | ||
| 357 | } | ||
| 358 | |||
| 346 | var isCurrentDomainTracked: Bool { | 359 | var isCurrentDomainTracked: Bool { |
| 347 | currentTrackedDomain != nil | 360 | currentTrackedDomain != nil |
| 348 | } | 361 | } |
| @@ -655,6 +668,12 @@ final class DomainViewModel { | |||
| 655 | rerunNavigationToken = UUID() | 668 | rerunNavigationToken = UUID() |
| 656 | } | 669 | } |
| 657 | 670 | ||
| 671 | func openInspection(for domain: String) { | ||
| 672 | self.domain = normalizedDomain(domain) | ||
| 673 | run() | ||
| 674 | rerunNavigationToken = UUID() | ||
| 675 | } | ||
| 676 | |||
| 658 | func reset() { | 677 | func reset() { |
| 659 | lookupTask?.cancel() | 678 | lookupTask?.cancel() |
| 660 | customPortScanTask?.cancel() | 679 | customPortScanTask?.cancel() |
| @@ -717,6 +736,101 @@ final class DomainViewModel { | |||
| 717 | } | 736 | } |
| 718 | } | 737 | } |
| 719 | 738 | ||
| 739 | func workflow(withID id: UUID) -> DomainWorkflow? { | ||
| 740 | workflows.first(where: { $0.id == id }) | ||
| 741 | } | ||
| 742 | |||
| 743 | func workflowsContaining(domain: String) -> [DomainWorkflow] { | ||
| 744 | let normalized = normalizedDomain(domain) | ||
| 745 | guard !normalized.isEmpty else { return [] } | ||
| 746 | return workflows.filter { workflow in | ||
| 747 | workflow.domains.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame }) | ||
| 748 | } | ||
| 749 | } | ||
| 750 | |||
| 751 | @discardableResult | ||
| 752 | func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? { | ||
| 753 | let normalizedDomains = normalizedDomains(domains) | ||
| 754 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 755 | guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil } | ||
| 756 | |||
| 757 | let workflow = DomainWorkflow( | ||
| 758 | name: trimmedName, | ||
| 759 | domains: normalizedDomains, | ||
| 760 | createdAt: Date(), | ||
| 761 | updatedAt: Date(), | ||
| 762 | notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty | ||
| 763 | ) | ||
| 764 | workflows.insert(workflow, at: 0) | ||
| 765 | persistWorkflows() | ||
| 766 | return workflow | ||
| 767 | } | ||
| 768 | |||
| 769 | func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) { | ||
| 770 | guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } | ||
| 771 | let normalizedDomains = normalizedDomains(domains) | ||
| 772 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 773 | guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return } | ||
| 774 | |||
| 775 | workflows[index].name = trimmedName | ||
| 776 | workflows[index].domains = normalizedDomains | ||
| 777 | workflows[index].notes = notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty | ||
| 778 | workflows[index].updatedAt = Date() | ||
| 779 | persistWorkflows() | ||
| 780 | } | ||
| 781 | |||
| 782 | func deleteWorkflow(_ workflow: DomainWorkflow) { | ||
| 783 | workflows.removeAll { $0.id == workflow.id } | ||
| 784 | if latestWorkflowRunSummary?.workflowID == workflow.id { | ||
| 785 | latestWorkflowRunSummary = nil | ||
| 786 | } | ||
| 787 | if activeWorkflowRunID == workflow.id { | ||
| 788 | activeWorkflowRunID = nil | ||
| 789 | activeWorkflowRunName = nil | ||
| 790 | } | ||
| 791 | persistWorkflows() | ||
| 792 | } | ||
| 793 | |||
| 794 | func addDomains(_ domains: [String], to workflow: DomainWorkflow) { | ||
| 795 | guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } | ||
| 796 | let mergedDomains = normalizedDomains(workflows[index].domains + domains) | ||
| 797 | guard mergedDomains != workflows[index].domains else { return } | ||
| 798 | workflows[index].domains = mergedDomains | ||
| 799 | workflows[index].updatedAt = Date() | ||
| 800 | persistWorkflows() | ||
| 801 | } | ||
| 802 | |||
| 803 | func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) { | ||
| 804 | guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } | ||
| 805 | workflows[index].domains.remove(atOffsets: offsets) | ||
| 806 | workflows[index].updatedAt = Date() | ||
| 807 | persistWorkflows() | ||
| 808 | } | ||
| 809 | |||
| 810 | func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) { | ||
| 811 | guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return } | ||
| 812 | workflows[index].domains.move(fromOffsets: offsets, toOffset: destination) | ||
| 813 | workflows[index].updatedAt = Date() | ||
| 814 | persistWorkflows() | ||
| 815 | } | ||
| 816 | |||
| 817 | func runWorkflow(_ workflow: DomainWorkflow) { | ||
| 818 | guard !workflow.domains.isEmpty else { return } | ||
| 819 | startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow) | ||
| 820 | } | ||
| 821 | |||
| 822 | func rerunCurrentDomain(in workflow: DomainWorkflow) { | ||
| 823 | guard workflow.domains.contains(where: { $0.caseInsensitiveCompare(searchedDomain) == .orderedSame }) else { | ||
| 824 | return | ||
| 825 | } | ||
| 826 | runWorkflow(workflow) | ||
| 827 | } | ||
| 828 | |||
| 829 | func refreshWorkflowList() async { | ||
| 830 | workflows = Self.loadWorkflows() | ||
| 831 | await Task.yield() | ||
| 832 | } | ||
| 833 | |||
| 720 | func runCustomPortScan(ports: [UInt16]) async { | 834 | func runCustomPortScan(ports: [UInt16]) async { |
| 721 | guard !searchedDomain.isEmpty else { | 835 | guard !searchedDomain.isEmpty else { |
| 722 | customPortScanError = "Run a domain lookup first" | 836 | customPortScanError = "Run a domain lookup first" |
| @@ -760,6 +874,11 @@ final class DomainViewModel { | |||
| 760 | return try? DomainReportExporter.data(for: currentReport, format: .json) | 874 | return try? DomainReportExporter.data(for: currentReport, format: .json) |
| 761 | } | 875 | } |
| 762 | 876 | ||
| 877 | func exportJSONString() -> String? { | ||
| 878 | guard let data = exportJSONData() else { return nil } | ||
| 879 | return String(data: data, encoding: .utf8) | ||
| 880 | } | ||
| 881 | |||
| 763 | func exportBatchText() -> String { | 882 | func exportBatchText() -> String { |
| 764 | DomainReportExporter.batchText( | 883 | DomainReportExporter.batchText( |
| 765 | for: currentBatchReports(), | 884 | for: currentBatchReports(), |
| @@ -798,6 +917,25 @@ final class DomainViewModel { | |||
| 798 | ) | 917 | ) |
| 799 | } | 918 | } |
| 800 | 919 | ||
| 920 | func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String { | ||
| 921 | DomainReportExporter.batchText( | ||
| 922 | for: workflowReports(from: summary, changedOnly: changedOnly), | ||
| 923 | title: "\(summary.workflowName) Workflow Export" | ||
| 924 | ) | ||
| 925 | } | ||
| 926 | |||
| 927 | func exportWorkflowCSV(summary: WorkflowRunSummary, changedOnly: Bool) -> String { | ||
| 928 | DomainReportExporter.csv(for: workflowReports(from: summary, changedOnly: changedOnly)) | ||
| 929 | } | ||
| 930 | |||
| 931 | func exportWorkflowJSONData(summary: WorkflowRunSummary, changedOnly: Bool) -> Data? { | ||
| 932 | try? DomainReportExporter.data( | ||
| 933 | for: workflowReports(from: summary, changedOnly: changedOnly), | ||
| 934 | format: .json, | ||
| 935 | title: "\(summary.workflowName) Workflow Export" | ||
| 936 | ) | ||
| 937 | } | ||
| 938 | |||
| 801 | private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { | 939 | private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { |
| 802 | let previous = previousSnapshot( | 940 | let previous = previousSnapshot( |
| 803 | for: domain, | 941 | for: domain, |
| @@ -1533,7 +1671,7 @@ final class DomainViewModel { | |||
| 1533 | return lookupID | 1671 | return lookupID |
| 1534 | } | 1672 | } |
| 1535 | 1673 | ||
| 1536 | private func startBatchLookup(domains: [String], source: BatchLookupSource) { | 1674 | private func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) { |
| 1537 | guard !domains.isEmpty else { return } | 1675 | guard !domains.isEmpty else { return } |
| 1538 | guard !batchLookupRunning else { return } | 1676 | guard !batchLookupRunning else { return } |
| 1539 | 1677 | ||
| @@ -1545,6 +1683,8 @@ final class DomainViewModel { | |||
| 1545 | lastBatchStartedAt = now | 1683 | lastBatchStartedAt = now |
| 1546 | clearBatchState() | 1684 | clearBatchState() |
| 1547 | batchLookupSource = source | 1685 | batchLookupSource = source |
| 1686 | activeWorkflowRunID = workflow?.id | ||
| 1687 | activeWorkflowRunName = workflow?.name | ||
| 1548 | batchTotalCount = domains.count | 1688 | batchTotalCount = domains.count |
| 1549 | batchLookupRunning = true | 1689 | batchLookupRunning = true |
| 1550 | batchResults = domains.map { | 1690 | batchResults = domains.map { |
| @@ -1578,7 +1718,10 @@ final class DomainViewModel { | |||
| 1578 | batchTotalCount = 0 | 1718 | batchTotalCount = 0 |
| 1579 | batchLookupRunning = false | 1719 | batchLookupRunning = false |
| 1580 | latestBatchSweepSummary = nil | 1720 | latestBatchSweepSummary = nil |
| 1721 | latestWorkflowRunSummary = nil | ||
| 1581 | activeBatchDomains = [] | 1722 | activeBatchDomains = [] |
| 1723 | activeWorkflowRunID = nil | ||
| 1724 | activeWorkflowRunName = nil | ||
| 1582 | batchTask = nil | 1725 | batchTask = nil |
| 1583 | } | 1726 | } |
| 1584 | 1727 | ||
| @@ -1691,12 +1834,16 @@ final class DomainViewModel { | |||
| 1691 | refreshingTrackedDomainID = nil | 1834 | refreshingTrackedDomainID = nil |
| 1692 | batchTask = nil | 1835 | batchTask = nil |
| 1693 | 1836 | ||
| 1837 | let changedCount = batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count | ||
| 1838 | let unchangedCount = batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count | ||
| 1839 | let warningCount = batchResults.filter { $0.certificateWarningLevel != .none }.count | ||
| 1840 | |||
| 1694 | let summary = BatchSweepSummary( | 1841 | let summary = BatchSweepSummary( |
| 1695 | source: source, | 1842 | source: source, |
| 1696 | totalDomains: batchResults.count, | 1843 | totalDomains: batchResults.count, |
| 1697 | changedDomains: batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count, | 1844 | changedDomains: changedCount, |
| 1698 | unchangedDomains: batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count, | 1845 | unchangedDomains: unchangedCount, |
| 1699 | warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count, | 1846 | warningDomains: warningCount, |
| 1700 | results: batchResults.sorted { lhs, rhs in | 1847 | results: batchResults.sorted { lhs, rhs in |
| 1701 | if lhs.status != rhs.status { | 1848 | if lhs.status != rhs.status { |
| 1702 | return lhs.status.rawValue < rhs.status.rawValue | 1849 | return lhs.status.rawValue < rhs.status.rawValue |
| @@ -1707,7 +1854,20 @@ final class DomainViewModel { | |||
| 1707 | ) | 1854 | ) |
| 1708 | latestBatchSweepSummary = summary | 1855 | latestBatchSweepSummary = summary |
| 1709 | 1856 | ||
| 1710 | if notificationsAuthorized { | 1857 | if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName { |
| 1858 | latestWorkflowRunSummary = WorkflowRunSummary( | ||
| 1859 | workflowID: activeWorkflowRunID, | ||
| 1860 | workflowName: activeWorkflowRunName, | ||
| 1861 | totalDomains: batchResults.count, | ||
| 1862 | changedDomains: changedCount, | ||
| 1863 | unchangedDomains: unchangedCount, | ||
| 1864 | warningDomains: warningCount, | ||
| 1865 | results: summary.results, | ||
| 1866 | generatedAt: summary.generatedAt | ||
| 1867 | ) | ||
| 1868 | } | ||
| 1869 | |||
| 1870 | if notificationsAuthorized, source != .workflow { | ||
| 1711 | Task { | 1871 | Task { |
| 1712 | await LocalNotificationService.shared.notifySweepComplete(summary: summary) | 1872 | await LocalNotificationService.shared.notifySweepComplete(summary: summary) |
| 1713 | } | 1873 | } |
| @@ -1895,6 +2055,14 @@ final class DomainViewModel { | |||
| 1895 | currentBatchResultEntries.map(report(for:)) | 2055 | currentBatchResultEntries.map(report(for:)) |
| 1896 | } | 2056 | } |
| 1897 | 2057 | ||
| 2058 | private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] { | ||
| 2059 | let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results | ||
| 2060 | return filteredResults.compactMap { result in | ||
| 2061 | guard let entry = historyEntry(for: result) else { return nil } | ||
| 2062 | return report(for: entry) | ||
| 2063 | } | ||
| 2064 | } | ||
| 2065 | |||
| 1898 | private func reports(for domains: [TrackedDomain]) -> [DomainReport] { | 2066 | private func reports(for domains: [TrackedDomain]) -> [DomainReport] { |
| 1899 | let latestEntries = latestSnapshots(for: domains) | 2067 | let latestEntries = latestSnapshots(for: domains) |
| 1900 | 2068 | ||
| @@ -2024,6 +2192,27 @@ final class DomainViewModel { | |||
| 2024 | return [] | 2192 | return [] |
| 2025 | } | 2193 | } |
| 2026 | 2194 | ||
| 2195 | private func persistWorkflows() { | ||
| 2196 | if let data = try? JSONEncoder().encode(workflows) { | ||
| 2197 | UserDefaults.standard.set(data, forKey: Self.workflowsKey) | ||
| 2198 | } | ||
| 2199 | } | ||
| 2200 | |||
| 2201 | private static func loadWorkflows() -> [DomainWorkflow] { | ||
| 2202 | let defaults = UserDefaults.standard | ||
| 2203 | guard let data = defaults.data(forKey: workflowsKey), | ||
| 2204 | let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else { | ||
| 2205 | return [] | ||
| 2206 | } | ||
| 2207 | |||
| 2208 | return workflows.sorted { lhs, rhs in | ||
| 2209 | if lhs.updatedAt != rhs.updatedAt { | ||
| 2210 | return lhs.updatedAt > rhs.updatedAt | ||
| 2211 | } | ||
| 2212 | return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending | ||
| 2213 | } | ||
| 2214 | } | ||
| 2215 | |||
| 2027 | private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] { | 2216 | private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] { |
| 2028 | var seen = Set<String>() | 2217 | var seen = Set<String>() |
| 2029 | return domains.filter { domain in | 2218 | return domains.filter { domain in |
| @@ -2032,6 +2221,14 @@ final class DomainViewModel { | |||
| 2032 | } | 2221 | } |
| 2033 | } | 2222 | } |
| 2034 | 2223 | ||
| 2224 | private func normalizedDomains(_ domains: [String]) -> [String] { | ||
| 2225 | var seen = Set<String>() | ||
| 2226 | return domains | ||
| 2227 | .map(normalizedDomain) | ||
| 2228 | .filter { !$0.isEmpty } | ||
| 2229 | .filter { seen.insert($0).inserted } | ||
| 2230 | } | ||
| 2231 | |||
| 2035 | private func historySortPredicate(lhs: HistoryEntry, rhs: HistoryEntry) -> Bool { | 2232 | private func historySortPredicate(lhs: HistoryEntry, rhs: HistoryEntry) -> Bool { |
| 2036 | switch historySortOption { | 2233 | switch historySortOption { |
| 2037 | case .newest: | 2234 | case .newest: |
DomainDig/HistoryView.swift +19 −1
| @@ -5,6 +5,7 @@ struct HistoryView: View { | |||
| 5 | @Bindable var viewModel: DomainViewModel | 5 | @Bindable var viewModel: DomainViewModel |
| 6 | @Environment(\.dismiss) private var dismiss | 6 | @Environment(\.dismiss) private var dismiss |
| 7 | @State private var showClearAllConfirmation = false | 7 | @State private var showClearAllConfirmation = false |
| 8 | @State private var showWorkflowAddSheet = false | ||
| 8 | 9 | ||
| 9 | private var groupedHistory: [HistoryGroup] { | 10 | private var groupedHistory: [HistoryGroup] { |
| 10 | HistoryGroup.groups(for: viewModel.filteredHistory) | 11 | HistoryGroup.groups(for: viewModel.filteredHistory) |
| @@ -105,6 +106,12 @@ struct HistoryView: View { | |||
| 105 | Button("Clear All", role: .destructive) { | 106 | Button("Clear All", role: .destructive) { |
| 106 | showClearAllConfirmation = true | 107 | showClearAllConfirmation = true |
| 107 | } | 108 | } |
| 109 | |||
| 110 | if !viewModel.filteredHistory.isEmpty { | ||
| 111 | Button("Add to Workflow") { | ||
| 112 | showWorkflowAddSheet = true | ||
| 113 | } | ||
| 114 | } | ||
| 108 | } label: { | 115 | } label: { |
| 109 | Image(systemName: "line.3.horizontal.decrease.circle") | 116 | Image(systemName: "line.3.horizontal.decrease.circle") |
| 110 | } | 117 | } |
| @@ -121,6 +128,13 @@ struct HistoryView: View { | |||
| 121 | } message: { | 128 | } message: { |
| 122 | Text("This will delete all saved history entries. This cannot be undone.") | 129 | Text("This will delete all saved history entries. This cannot be undone.") |
| 123 | } | 130 | } |
| 131 | .sheet(isPresented: $showWorkflowAddSheet) { | ||
| 132 | WorkflowBulkAddSheet( | ||
| 133 | viewModel: viewModel, | ||
| 134 | title: "Add History Domains", | ||
| 135 | availableDomains: Array(Set(viewModel.filteredHistory.map(\.domain))).sorted() | ||
| 136 | ) | ||
| 137 | } | ||
| 124 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in | 138 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in |
| 125 | dismiss() | 139 | dismiss() |
| 126 | } | 140 | } |
| @@ -170,6 +184,7 @@ struct HistoryDetailView: View { | |||
| 170 | confidence: snapshot.availabilityConfidence, | 184 | confidence: snapshot.availabilityConfidence, |
| 171 | snapshotNote: entry.note, | 185 | snapshotNote: entry.note, |
| 172 | trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }), | 186 | trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }), |
| 187 | workflows: viewModel.workflowsContaining(domain: entry.domain), | ||
| 173 | trackingLimitMessage: nil, | 188 | trackingLimitMessage: nil, |
| 174 | onTrack: { | 189 | onTrack: { |
| 175 | _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) | 190 | _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) |
| @@ -178,7 +193,10 @@ struct HistoryDetailView: View { | |||
| 178 | guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return } | 193 | guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return } |
| 179 | viewModel.togglePinned(for: trackedDomain) | 194 | viewModel.togglePinned(for: trackedDomain) |
| 180 | }, | 195 | }, |
| 181 | onEditNote: nil | 196 | onEditNote: nil, |
| 197 | onAddToWorkflow: nil, | ||
| 198 | onOpenWorkflow: nil, | ||
| 199 | onRunWorkflow: nil | ||
| 182 | ) | 200 | ) |
| 183 | .padding(.top, appDensity.metrics.sectionSpacing) | 201 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 184 | OwnershipSectionView( | 202 | OwnershipSectionView( |
DomainDig/Models.swift +45
| @@ -223,6 +223,7 @@ struct DomainChangeSummary: Codable, Equatable { | |||
| 223 | enum BatchLookupSource: String, Codable { | 223 | enum BatchLookupSource: String, Codable { |
| 224 | case manual | 224 | case manual |
| 225 | case watchlistRefresh | 225 | case watchlistRefresh |
| 226 | case workflow | ||
| 226 | } | 227 | } |
| 227 | 228 | ||
| 228 | enum BatchLookupStatus: String, Codable { | 229 | enum BatchLookupStatus: String, Codable { |
| @@ -276,6 +277,13 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { | |||
| 276 | self.status = status | 277 | self.status = status |
| 277 | self.errorMessage = errorMessage | 278 | self.errorMessage = errorMessage |
| 278 | } | 279 | } |
| 280 | |||
| 281 | var hasMeaningfulChange: Bool { | ||
| 282 | quickStatus == "Changed" | ||
| 283 | || quickStatus == "High" | ||
| 284 | || certificateWarningLevel != .none | ||
| 285 | || status == .failed | ||
| 286 | } | ||
| 279 | } | 287 | } |
| 280 | 288 | ||
| 281 | struct BatchSweepSummary: Identifiable, Equatable { | 289 | struct BatchSweepSummary: Identifiable, Equatable { |
| @@ -289,6 +297,43 @@ struct BatchSweepSummary: Identifiable, Equatable { | |||
| 289 | let generatedAt: Date | 297 | let generatedAt: Date |
| 290 | } | 298 | } |
| 291 | 299 | ||
| 300 | struct DomainWorkflow: Codable, Identifiable, Equatable { | ||
| 301 | let id: UUID | ||
| 302 | var name: String | ||
| 303 | var domains: [String] | ||
| 304 | var createdAt: Date | ||
| 305 | var updatedAt: Date | ||
| 306 | var notes: String? | ||
| 307 | |||
| 308 | init( | ||
| 309 | id: UUID = UUID(), | ||
| 310 | name: String, | ||
| 311 | domains: [String], | ||
| 312 | createdAt: Date = Date(), | ||
| 313 | updatedAt: Date = Date(), | ||
| 314 | notes: String? = nil | ||
| 315 | ) { | ||
| 316 | self.id = id | ||
| 317 | self.name = name | ||
| 318 | self.domains = domains | ||
| 319 | self.createdAt = createdAt | ||
| 320 | self.updatedAt = updatedAt | ||
| 321 | self.notes = notes | ||
| 322 | } | ||
| 323 | } | ||
| 324 | |||
| 325 | struct WorkflowRunSummary: Identifiable, Equatable { | ||
| 326 | let id = UUID() | ||
| 327 | let workflowID: UUID | ||
| 328 | let workflowName: String | ||
| 329 | let totalDomains: Int | ||
| 330 | let changedDomains: Int | ||
| 331 | let unchangedDomains: Int | ||
| 332 | let warningDomains: Int | ||
| 333 | let results: [BatchLookupResult] | ||
| 334 | let generatedAt: Date | ||
| 335 | } | ||
| 336 | |||
| 292 | enum DataCapability: String, Codable { | 337 | enum DataCapability: String, Codable { |
| 293 | case ownershipHistory | 338 | case ownershipHistory |
| 294 | case dnsHistory | 339 | case dnsHistory |
DomainDig/WatchlistView.swift +12
| @@ -4,6 +4,7 @@ struct WatchlistView: View { | |||
| 4 | @Environment(\.appDensity) private var appDensity | 4 | @Environment(\.appDensity) private var appDensity |
| 5 | @Bindable var viewModel: DomainViewModel | 5 | @Bindable var viewModel: DomainViewModel |
| 6 | @Environment(\.dismiss) private var dismiss | 6 | @Environment(\.dismiss) private var dismiss |
| 7 | @State private var showWorkflowAddSheet = false | ||
| 7 | 8 | ||
| 8 | private var pinnedDomains: [TrackedDomain] { | 9 | private var pinnedDomains: [TrackedDomain] { |
| 9 | viewModel.filteredTrackedDomains.filter(\.isPinned) | 10 | viewModel.filteredTrackedDomains.filter(\.isPinned) |
| @@ -99,6 +100,10 @@ struct WatchlistView: View { | |||
| 99 | } | 100 | } |
| 100 | .disabled(viewModel.batchLookupRunning) | 101 | .disabled(viewModel.batchLookupRunning) |
| 101 | 102 | ||
| 103 | Button("Add to Workflow") { | ||
| 104 | showWorkflowAddSheet = true | ||
| 105 | } | ||
| 106 | |||
| 102 | Button("Export TXT") { | 107 | Button("Export TXT") { |
| 103 | shareTrackedDomains(format: .text) | 108 | shareTrackedDomains(format: .text) |
| 104 | } | 109 | } |
| @@ -124,6 +129,13 @@ struct WatchlistView: View { | |||
| 124 | .sheet(item: batchSummaryBinding) { summary in | 129 | .sheet(item: batchSummaryBinding) { summary in |
| 125 | BatchSweepSummaryView(viewModel: viewModel, summary: summary) | 130 | BatchSweepSummaryView(viewModel: viewModel, summary: summary) |
| 126 | } | 131 | } |
| 132 | .sheet(isPresented: $showWorkflowAddSheet) { | ||
| 133 | WorkflowBulkAddSheet( | ||
| 134 | viewModel: viewModel, | ||
| 135 | title: "Add Watchlist Domains", | ||
| 136 | availableDomains: viewModel.filteredTrackedDomains.map(\.domain) | ||
| 137 | ) | ||
| 138 | } | ||
| 127 | .preferredColorScheme(.dark) | 139 | .preferredColorScheme(.dark) |
| 128 | } | 140 | } |
| 129 | 141 | ||
DomainDig/WorkflowsView.swift added +634
| @@ -0,0 +1,634 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | private enum WorkflowDestinationMode: String, CaseIterable, Identifiable { | ||
| 4 | case existing | ||
| 5 | case new | ||
| 6 | |||
| 7 | var id: String { rawValue } | ||
| 8 | } | ||
| 9 | |||
| 10 | struct WorkflowsView: View { | ||
| 11 | @Environment(\.appDensity) private var appDensity | ||
| 12 | @Bindable var viewModel: DomainViewModel | ||
| 13 | |||
| 14 | @State private var showingCreateWorkflow = false | ||
| 15 | |||
| 16 | var body: some View { | ||
| 17 | List { | ||
| 18 | if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { | ||
| 19 | Section("Workflow Run") { | ||
| 20 | VStack(alignment: .leading, spacing: 8) { | ||
| 21 | ProgressView( | ||
| 22 | value: Double(viewModel.batchCompletedCount), | ||
| 23 | total: Double(max(viewModel.batchTotalCount, 1)) | ||
| 24 | ) | ||
| 25 | .tint(.cyan) | ||
| 26 | |||
| 27 | HStack { | ||
| 28 | Text(viewModel.batchProgressLabel) | ||
| 29 | .font(appDensity.font(.caption)) | ||
| 30 | .foregroundStyle(.secondary) | ||
| 31 | Spacer() | ||
| 32 | if viewModel.batchLookupRunning { | ||
| 33 | Button("Cancel") { | ||
| 34 | viewModel.cancelBatchLookup() | ||
| 35 | } | ||
| 36 | .buttonStyle(.bordered) | ||
| 37 | .font(appDensity.font(.caption2)) | ||
| 38 | } | ||
| 39 | } | ||
| 40 | } | ||
| 41 | .padding(.vertical, 4) | ||
| 42 | } | ||
| 43 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | ||
| 44 | } | ||
| 45 | |||
| 46 | if viewModel.workflows.isEmpty { | ||
| 47 | Section { | ||
| 48 | EmptyStateCardView( | ||
| 49 | title: "No Workflows Yet", | ||
| 50 | message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.", | ||
| 51 | suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.", | ||
| 52 | systemImage: "square.stack.3d.down.right" | ||
| 53 | ) | ||
| 54 | } | ||
| 55 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | ||
| 56 | } else { | ||
| 57 | ForEach(viewModel.workflows) { workflow in | ||
| 58 | NavigationLink { | ||
| 59 | WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id) | ||
| 60 | } label: { | ||
| 61 | WorkflowRowView(workflow: workflow) | ||
| 62 | } | ||
| 63 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | ||
| 64 | } | ||
| 65 | .onDelete { offsets in | ||
| 66 | let workflows = offsets.map { viewModel.workflows[$0] } | ||
| 67 | workflows.forEach(viewModel.deleteWorkflow) | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | .scrollContentBackground(.hidden) | ||
| 72 | .background(Color.black) | ||
| 73 | .refreshable { | ||
| 74 | await viewModel.refreshWorkflowList() | ||
| 75 | } | ||
| 76 | .navigationTitle("Workflows") | ||
| 77 | .toolbar { | ||
| 78 | ToolbarItemGroup(placement: .topBarTrailing) { | ||
| 79 | Button { | ||
| 80 | showingCreateWorkflow = true | ||
| 81 | } label: { | ||
| 82 | Image(systemName: "plus.circle") | ||
| 83 | } | ||
| 84 | |||
| 85 | if !viewModel.workflows.isEmpty { | ||
| 86 | EditButton() | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | .sheet(isPresented: $showingCreateWorkflow) { | ||
| 91 | WorkflowComposerView(viewModel: viewModel) | ||
| 92 | } | ||
| 93 | .sheet(item: workflowSummaryBinding) { summary in | ||
| 94 | WorkflowRunSummaryView(viewModel: viewModel, summary: summary) | ||
| 95 | } | ||
| 96 | .preferredColorScheme(.dark) | ||
| 97 | } | ||
| 98 | |||
| 99 | private var workflowSummaryBinding: Binding<WorkflowRunSummary?> { | ||
| 100 | Binding( | ||
| 101 | get: { viewModel.latestWorkflowRunSummary }, | ||
| 102 | set: { viewModel.latestWorkflowRunSummary = $0 } | ||
| 103 | ) | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | private struct WorkflowRowView: View { | ||
| 108 | @Environment(\.appDensity) private var appDensity | ||
| 109 | |||
| 110 | let workflow: DomainWorkflow | ||
| 111 | |||
| 112 | var body: some View { | ||
| 113 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { | ||
| 114 | HStack(alignment: .firstTextBaseline, spacing: 8) { | ||
| 115 | Text(workflow.name) | ||
| 116 | .font(appDensity.font(.callout, design: .default, weight: .semibold)) | ||
| 117 | .foregroundStyle(.primary) | ||
| 118 | .lineLimit(2) | ||
| 119 | Spacer(minLength: 8) | ||
| 120 | Text("\(workflow.domains.count) domains") | ||
| 121 | .font(appDensity.font(.caption2)) | ||
| 122 | .foregroundStyle(.secondary) | ||
| 123 | } | ||
| 124 | |||
| 125 | Text("Updated \(workflow.updatedAt.formatted(date: .abbreviated, time: .shortened))") | ||
| 126 | .font(appDensity.font(.caption2)) | ||
| 127 | .foregroundStyle(.secondary) | ||
| 128 | |||
| 129 | if let notes = workflow.notes, !notes.isEmpty { | ||
| 130 | Text(notes) | ||
| 131 | .font(appDensity.font(.caption)) | ||
| 132 | .foregroundStyle(.secondary) | ||
| 133 | .lineLimit(2) | ||
| 134 | } | ||
| 135 | } | ||
| 136 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 137 | .padding(.vertical, 4) | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | struct WorkflowDetailView: View { | ||
| 142 | @Environment(\.appDensity) private var appDensity | ||
| 143 | @Bindable var viewModel: DomainViewModel | ||
| 144 | let workflowID: UUID | ||
| 145 | |||
| 146 | @State private var showingEditor = false | ||
| 147 | @State private var draftDomain = "" | ||
| 148 | @State private var includeAllExports = false | ||
| 149 | |||
| 150 | private var workflow: DomainWorkflow? { | ||
| 151 | viewModel.workflow(withID: workflowID) | ||
| 152 | } | ||
| 153 | |||
| 154 | private var latestSummary: WorkflowRunSummary? { | ||
| 155 | guard viewModel.latestWorkflowRunSummary?.workflowID == workflowID else { return nil } | ||
| 156 | return viewModel.latestWorkflowRunSummary | ||
| 157 | } | ||
| 158 | |||
| 159 | var body: some View { | ||
| 160 | Group { | ||
| 161 | if let workflow { | ||
| 162 | List { | ||
| 163 | Section("Overview") { | ||
| 164 | statRow(label: "Domains", value: "\(workflow.domains.count)") | ||
| 165 | statRow(label: "Created", value: workflow.createdAt.formatted(date: .abbreviated, time: .shortened)) | ||
| 166 | statRow(label: "Updated", value: workflow.updatedAt.formatted(date: .abbreviated, time: .shortened)) | ||
| 167 | if let notes = workflow.notes, !notes.isEmpty { | ||
| 168 | Text(notes) | ||
| 169 | .font(appDensity.font(.caption)) | ||
| 170 | .foregroundStyle(.secondary) | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | Section("Actions") { | ||
| 175 | Button { | ||
| 176 | viewModel.runWorkflow(workflow) | ||
| 177 | } label: { | ||
| 178 | Label("Run Workflow", systemImage: "play.fill") | ||
| 179 | } | ||
| 180 | .disabled(workflow.domains.isEmpty || viewModel.batchLookupRunning) | ||
| 181 | |||
| 182 | if viewModel.batchLookupRunning, viewModel.batchLookupSource == .workflow { | ||
| 183 | Button("Cancel Workflow Run", role: .destructive) { | ||
| 184 | viewModel.cancelBatchLookup() | ||
| 185 | } | ||
| 186 | } | ||
| 187 | |||
| 188 | Button { | ||
| 189 | showingEditor = true | ||
| 190 | } label: { | ||
| 191 | Label("Edit Workflow", systemImage: "pencil") | ||
| 192 | } | ||
| 193 | |||
| 194 | if latestSummary != nil { | ||
| 195 | Toggle("Export all domains", isOn: $includeAllExports) | ||
| 196 | Menu { | ||
| 197 | Button("Export TXT") { | ||
| 198 | shareWorkflowResults(format: .text) | ||
| 199 | } | ||
| 200 | Button("Export CSV") { | ||
| 201 | shareWorkflowResults(format: .csv) | ||
| 202 | } | ||
| 203 | Button("Export JSON") { | ||
| 204 | shareWorkflowResults(format: .json) | ||
| 205 | } | ||
| 206 | } label: { | ||
| 207 | Label("Export Results", systemImage: "square.and.arrow.up") | ||
| 208 | } | ||
| 209 | } | ||
| 210 | } | ||
| 211 | |||
| 212 | if let latestSummary { | ||
| 213 | Section("Latest Run") { | ||
| 214 | statRow(label: "Processed", value: "\(latestSummary.totalDomains)") | ||
| 215 | statRow(label: "Changed", value: "\(latestSummary.changedDomains)") | ||
| 216 | statRow(label: "Warnings", value: "\(latestSummary.warningDomains)") | ||
| 217 | statRow(label: "Unchanged", value: "\(latestSummary.unchangedDomains)") | ||
| 218 | |||
| 219 | Button { | ||
| 220 | viewModel.latestWorkflowRunSummary = latestSummary | ||
| 221 | } label: { | ||
| 222 | Label("View Run Summary", systemImage: "list.bullet.rectangle") | ||
| 223 | } | ||
| 224 | } | ||
| 225 | } | ||
| 226 | |||
| 227 | Section("Add Domain") { | ||
| 228 | HStack { | ||
| 229 | TextField("example.com", text: $draftDomain) | ||
| 230 | .textInputAutocapitalization(.never) | ||
| 231 | .autocorrectionDisabled() | ||
| 232 | Button("Add") { | ||
| 233 | guard !draftDomain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } | ||
| 234 | viewModel.addDomains([draftDomain], to: workflow) | ||
| 235 | draftDomain = "" | ||
| 236 | } | ||
| 237 | } | ||
| 238 | } | ||
| 239 | |||
| 240 | Section("Domains") { | ||
| 241 | if workflow.domains.isEmpty { | ||
| 242 | Text("No domains in this workflow") | ||
| 243 | .font(appDensity.font(.caption)) | ||
| 244 | .foregroundStyle(.secondary) | ||
| 245 | } else { | ||
| 246 | ForEach(workflow.domains, id: \.self) { domain in | ||
| 247 | Button { | ||
| 248 | viewModel.openInspection(for: domain) | ||
| 249 | } label: { | ||
| 250 | HStack { | ||
| 251 | Text(domain) | ||
| 252 | .font(appDensity.font(.callout)) | ||
| 253 | .foregroundStyle(.primary) | ||
| 254 | Spacer() | ||
| 255 | Image(systemName: "arrow.up.right.circle") | ||
| 256 | .foregroundStyle(.secondary) | ||
| 257 | } | ||
| 258 | } | ||
| 259 | .buttonStyle(.plain) | ||
| 260 | } | ||
| 261 | .onDelete { offsets in | ||
| 262 | viewModel.removeWorkflowDomains(at: offsets, from: workflow) | ||
| 263 | } | ||
| 264 | .onMove { offsets, destination in | ||
| 265 | viewModel.moveWorkflowDomains(from: offsets, to: destination, in: workflow) | ||
| 266 | } | ||
| 267 | } | ||
| 268 | } | ||
| 269 | } | ||
| 270 | .scrollContentBackground(.hidden) | ||
| 271 | .background(Color.black) | ||
| 272 | .navigationTitle(workflow.name) | ||
| 273 | .toolbar { | ||
| 274 | if !workflow.domains.isEmpty { | ||
| 275 | EditButton() | ||
| 276 | } | ||
| 277 | } | ||
| 278 | .sheet(isPresented: $showingEditor) { | ||
| 279 | WorkflowComposerView(viewModel: viewModel, workflow: workflow) | ||
| 280 | } | ||
| 281 | .sheet(item: workflowSummaryBinding) { summary in | ||
| 282 | WorkflowRunSummaryView(viewModel: viewModel, summary: summary) | ||
| 283 | } | ||
| 284 | } else { | ||
| 285 | Text("Workflow not found") | ||
| 286 | .font(appDensity.font(.callout)) | ||
| 287 | .foregroundStyle(.secondary) | ||
| 288 | } | ||
| 289 | } | ||
| 290 | .preferredColorScheme(.dark) | ||
| 291 | } | ||
| 292 | |||
| 293 | private func statRow(label: String, value: String) -> some View { | ||
| 294 | HStack { | ||
| 295 | Text(label) | ||
| 296 | .font(appDensity.font(.caption)) | ||
| 297 | .foregroundStyle(.secondary) | ||
| 298 | Spacer() | ||
| 299 | Text(value) | ||
| 300 | .font(appDensity.font(.callout)) | ||
| 301 | .foregroundStyle(.primary) | ||
| 302 | } | ||
| 303 | } | ||
| 304 | |||
| 305 | private var workflowSummaryBinding: Binding<WorkflowRunSummary?> { | ||
| 306 | Binding( | ||
| 307 | get: { | ||
| 308 | guard viewModel.latestWorkflowRunSummary?.workflowID == workflowID else { return nil } | ||
| 309 | return viewModel.latestWorkflowRunSummary | ||
| 310 | }, | ||
| 311 | set: { _ in | ||
| 312 | viewModel.latestWorkflowRunSummary = nil | ||
| 313 | } | ||
| 314 | ) | ||
| 315 | } | ||
| 316 | |||
| 317 | private func shareWorkflowResults(format: DomainExportFormat) { | ||
| 318 | guard let latestSummary else { return } | ||
| 319 | |||
| 320 | let changedOnly = !includeAllExports | ||
| 321 | let formatter = DateFormatter() | ||
| 322 | formatter.dateFormat = "yyyyMMdd_HHmmss" | ||
| 323 | let timestamp = formatter.string(from: Date()) | ||
| 324 | let filename = "\(timestamp)_workflow_\(latestSummary.workflowName.replacingOccurrences(of: " ", with: "_").lowercased()).\(format.fileExtension)" | ||
| 325 | let data: Data | ||
| 326 | |||
| 327 | switch format { | ||
| 328 | case .text: | ||
| 329 | data = Data(viewModel.exportWorkflowText(summary: latestSummary, changedOnly: changedOnly).utf8) | ||
| 330 | case .csv: | ||
| 331 | data = Data(viewModel.exportWorkflowCSV(summary: latestSummary, changedOnly: changedOnly).utf8) | ||
| 332 | case .json: | ||
| 333 | data = viewModel.exportWorkflowJSONData(summary: latestSummary, changedOnly: changedOnly) ?? Data("[]".utf8) | ||
| 334 | } | ||
| 335 | |||
| 336 | ExportPresenter.share(filename: filename, data: data) | ||
| 337 | } | ||
| 338 | } | ||
| 339 | |||
| 340 | struct WorkflowComposerView: View { | ||
| 341 | @Environment(\.dismiss) private var dismiss | ||
| 342 | @Bindable var viewModel: DomainViewModel | ||
| 343 | let workflow: DomainWorkflow? | ||
| 344 | |||
| 345 | @State private var name = "" | ||
| 346 | @State private var domainsText = "" | ||
| 347 | @State private var notes = "" | ||
| 348 | |||
| 349 | init(viewModel: DomainViewModel, workflow: DomainWorkflow? = nil) { | ||
| 350 | self.viewModel = viewModel | ||
| 351 | self.workflow = workflow | ||
| 352 | _name = State(initialValue: workflow?.name ?? "") | ||
| 353 | _domainsText = State(initialValue: workflow?.domains.joined(separator: "\n") ?? "") | ||
| 354 | _notes = State(initialValue: workflow?.notes ?? "") | ||
| 355 | } | ||
| 356 | |||
| 357 | var body: some View { | ||
| 358 | NavigationStack { | ||
| 359 | Form { | ||
| 360 | Section("Workflow") { | ||
| 361 | TextField("Name", text: $name) | ||
| 362 | TextField("Notes", text: $notes, axis: .vertical) | ||
| 363 | .lineLimit(3...6) | ||
| 364 | } | ||
| 365 | |||
| 366 | Section("Domains") { | ||
| 367 | TextField("example.com\napple.com", text: $domainsText, axis: .vertical) | ||
| 368 | .textInputAutocapitalization(.never) | ||
| 369 | .autocorrectionDisabled() | ||
| 370 | .lineLimit(6...14) | ||
| 371 | } | ||
| 372 | } | ||
| 373 | .navigationTitle(workflow == nil ? "New Workflow" : "Edit Workflow") | ||
| 374 | .toolbar { | ||
| 375 | ToolbarItem(placement: .cancellationAction) { | ||
| 376 | Button("Cancel") { | ||
| 377 | dismiss() | ||
| 378 | } | ||
| 379 | } | ||
| 380 | ToolbarItem(placement: .confirmationAction) { | ||
| 381 | Button("Save") { | ||
| 382 | saveWorkflow() | ||
| 383 | } | ||
| 384 | .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || parsedDomains.isEmpty) | ||
| 385 | } | ||
| 386 | } | ||
| 387 | } | ||
| 388 | .preferredColorScheme(.dark) | ||
| 389 | } | ||
| 390 | |||
| 391 | private var parsedDomains: [String] { | ||
| 392 | domainsText | ||
| 393 | .components(separatedBy: CharacterSet(charactersIn: ",\n")) | ||
| 394 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } | ||
| 395 | .filter { !$0.isEmpty } | ||
| 396 | } | ||
| 397 | |||
| 398 | private func saveWorkflow() { | ||
| 399 | if let workflow { | ||
| 400 | viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes) | ||
| 401 | } else { | ||
| 402 | _ = viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) | ||
| 403 | } | ||
| 404 | dismiss() | ||
| 405 | } | ||
| 406 | } | ||
| 407 | |||
| 408 | struct WorkflowRunSummaryView: View { | ||
| 409 | @Bindable var viewModel: DomainViewModel | ||
| 410 | let summary: WorkflowRunSummary | ||
| 411 | |||
| 412 | @State private var showAllResults = false | ||
| 413 | |||
| 414 | private var visibleResults: [BatchLookupResult] { | ||
| 415 | showAllResults ? summary.results : summary.results.filter(\.hasMeaningfulChange) | ||
| 416 | } | ||
| 417 | |||
| 418 | var body: some View { | ||
| 419 | NavigationStack { | ||
| 420 | List { | ||
| 421 | Section("Overview") { | ||
| 422 | statRow(label: "Workflow", value: summary.workflowName) | ||
| 423 | statRow(label: "Processed", value: "\(summary.totalDomains)") | ||
| 424 | statRow(label: "Changed", value: "\(summary.changedDomains)") | ||
| 425 | statRow(label: "Warnings", value: "\(summary.warningDomains)") | ||
| 426 | statRow(label: "Unchanged", value: "\(summary.unchangedDomains)") | ||
| 427 | statRow(label: "Finished", value: summary.generatedAt.formatted(date: .abbreviated, time: .shortened)) | ||
| 428 | } | ||
| 429 | |||
| 430 | Section { | ||
| 431 | Toggle("Show unchanged domains", isOn: $showAllResults) | ||
| 432 | } | ||
| 433 | |||
| 434 | Section(visibleResults.isEmpty ? "Meaningful Changes" : "Results") { | ||
| 435 | if visibleResults.isEmpty { | ||
| 436 | Text("No domains with meaningful changes or warnings") | ||
| 437 | .font(.system(.caption, design: .monospaced)) | ||
| 438 | .foregroundStyle(.secondary) | ||
| 439 | } else { | ||
| 440 | ForEach(visibleResults) { result in | ||
| 441 | if let entry = viewModel.historyEntry(for: result) { | ||
| 442 | NavigationLink { | ||
| 443 | HistoryDetailView(viewModel: viewModel, entry: entry) | ||
| 444 | } label: { | ||
| 445 | BatchResultRowView(result: result) | ||
| 446 | } | ||
| 447 | } else { | ||
| 448 | BatchResultRowView(result: result) | ||
| 449 | } | ||
| 450 | } | ||
| 451 | } | ||
| 452 | } | ||
| 453 | } | ||
| 454 | .navigationTitle("Workflow Summary") | ||
| 455 | .toolbar { | ||
| 456 | ToolbarItemGroup(placement: .topBarTrailing) { | ||
| 457 | Menu { | ||
| 458 | Button("Export TXT") { | ||
| 459 | share(format: .text) | ||
| 460 | } | ||
| 461 | Button("Export CSV") { | ||
| 462 | share(format: .csv) | ||
| 463 | } | ||
| 464 | Button("Export JSON") { | ||
| 465 | share(format: .json) | ||
| 466 | } | ||
| 467 | } label: { | ||
| 468 | Image(systemName: "square.and.arrow.up") | ||
| 469 | } | ||
| 470 | |||
| 471 | if let workflow = viewModel.workflow(withID: summary.workflowID) { | ||
| 472 | Button { | ||
| 473 | viewModel.runWorkflow(workflow) | ||
| 474 | } label: { | ||
| 475 | Image(systemName: "arrow.clockwise") | ||
| 476 | } | ||
| 477 | .disabled(viewModel.batchLookupRunning) | ||
| 478 | } | ||
| 479 | } | ||
| 480 | } | ||
| 481 | } | ||
| 482 | .preferredColorScheme(.dark) | ||
| 483 | } | ||
| 484 | |||
| 485 | private func statRow(label: String, value: String) -> some View { | ||
| 486 | HStack { | ||
| 487 | Text(label) | ||
| 488 | .font(.system(.caption, design: .monospaced)) | ||
| 489 | .foregroundStyle(.secondary) | ||
| 490 | Spacer() | ||
| 491 | Text(value) | ||
| 492 | .font(.system(.callout, design: .monospaced)) | ||
| 493 | .foregroundStyle(.primary) | ||
| 494 | } | ||
| 495 | } | ||
| 496 | |||
| 497 | private func share(format: DomainExportFormat) { | ||
| 498 | let changedOnly = !showAllResults | ||
| 499 | let formatter = DateFormatter() | ||
| 500 | formatter.dateFormat = "yyyyMMdd_HHmmss" | ||
| 501 | let timestamp = formatter.string(from: Date()) | ||
| 502 | let filename = "\(timestamp)_workflow_summary.\(format.fileExtension)" | ||
| 503 | let data: Data | ||
| 504 | |||
| 505 | switch format { | ||
| 506 | case .text: | ||
| 507 | data = Data(viewModel.exportWorkflowText(summary: summary, changedOnly: changedOnly).utf8) | ||
| 508 | case .csv: | ||
| 509 | data = Data(viewModel.exportWorkflowCSV(summary: summary, changedOnly: changedOnly).utf8) | ||
| 510 | case .json: | ||
| 511 | data = viewModel.exportWorkflowJSONData(summary: summary, changedOnly: changedOnly) ?? Data("[]".utf8) | ||
| 512 | } | ||
| 513 | |||
| 514 | ExportPresenter.share(filename: filename, data: data) | ||
| 515 | } | ||
| 516 | } | ||
| 517 | |||
| 518 | struct WorkflowBulkAddSheet: View { | ||
| 519 | @Environment(\.dismiss) private var dismiss | ||
| 520 | @Bindable var viewModel: DomainViewModel | ||
| 521 | let title: String | ||
| 522 | let availableDomains: [String] | ||
| 523 | |||
| 524 | @State private var mode: WorkflowDestinationMode = .existing | ||
| 525 | @State private var selectedDomains: Set<String> | ||
| 526 | @State private var selectedWorkflowID: UUID? | ||
| 527 | @State private var newWorkflowName = "" | ||
| 528 | @State private var newWorkflowNotes = "" | ||
| 529 | |||
| 530 | init(viewModel: DomainViewModel, title: String, availableDomains: [String]) { | ||
| 531 | self.viewModel = viewModel | ||
| 532 | self.title = title | ||
| 533 | self.availableDomains = Array(Set(availableDomains)).sorted() | ||
| 534 | _selectedDomains = State(initialValue: Set(availableDomains)) | ||
| 535 | _selectedWorkflowID = State(initialValue: viewModel.workflows.first?.id) | ||
| 536 | } | ||
| 537 | |||
| 538 | var body: some View { | ||
| 539 | NavigationStack { | ||
| 540 | List { | ||
| 541 | Section("Selection") { | ||
| 542 | ForEach(availableDomains, id: \.self) { domain in | ||
| 543 | Button { | ||
| 544 | toggle(domain) | ||
| 545 | } label: { | ||
| 546 | HStack { | ||
| 547 | Text(domain) | ||
| 548 | .foregroundStyle(.primary) | ||
| 549 | Spacer() | ||
| 550 | Image(systemName: selectedDomains.contains(domain) ? "checkmark.circle.fill" : "circle") | ||
| 551 | .foregroundStyle(selectedDomains.contains(domain) ? .cyan : .secondary) | ||
| 552 | } | ||
| 553 | } | ||
| 554 | .buttonStyle(.plain) | ||
| 555 | } | ||
| 556 | } | ||
| 557 | |||
| 558 | Section("Destination") { | ||
| 559 | Picker("Destination", selection: $mode) { | ||
| 560 | Text("Existing").tag(WorkflowDestinationMode.existing) | ||
| 561 | Text("New").tag(WorkflowDestinationMode.new) | ||
| 562 | } | ||
| 563 | .pickerStyle(.segmented) | ||
| 564 | |||
| 565 | if mode == .existing, !viewModel.workflows.isEmpty { | ||
| 566 | Picker("Workflow", selection: $selectedWorkflowID) { | ||
| 567 | ForEach(viewModel.workflows) { workflow in | ||
| 568 | Text(workflow.name).tag(Optional(workflow.id)) | ||
| 569 | } | ||
| 570 | } | ||
| 571 | } else { | ||
| 572 | TextField("Workflow name", text: $newWorkflowName) | ||
| 573 | TextField("Notes", text: $newWorkflowNotes, axis: .vertical) | ||
| 574 | .lineLimit(2...5) | ||
| 575 | } | ||
| 576 | } | ||
| 577 | } | ||
| 578 | .navigationTitle(title) | ||
| 579 | .toolbar { | ||
| 580 | ToolbarItem(placement: .cancellationAction) { | ||
| 581 | Button("Cancel") { | ||
| 582 | dismiss() | ||
| 583 | } | ||
| 584 | } | ||
| 585 | ToolbarItem(placement: .confirmationAction) { | ||
| 586 | Button("Save") { | ||
| 587 | save() | ||
| 588 | } | ||
| 589 | .disabled(!canSave) | ||
| 590 | } | ||
| 591 | } | ||
| 592 | .onAppear { | ||
| 593 | if viewModel.workflows.isEmpty { | ||
| 594 | mode = .new | ||
| 595 | } | ||
| 596 | } | ||
| 597 | } | ||
| 598 | .preferredColorScheme(.dark) | ||
| 599 | } | ||
| 600 | |||
| 601 | private var selectedDomainList: [String] { | ||
| 602 | availableDomains.filter { selectedDomains.contains($0) } | ||
| 603 | } | ||
| 604 | |||
| 605 | private var canSave: Bool { | ||
| 606 | guard !selectedDomainList.isEmpty else { return false } | ||
| 607 | switch mode { | ||
| 608 | case .existing: | ||
| 609 | return selectedWorkflowID != nil | ||
| 610 | case .new: | ||
| 611 | return !newWorkflowName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | ||
| 612 | } | ||
| 613 | } | ||
| 614 | |||
| 615 | private func toggle(_ domain: String) { | ||
| 616 | if selectedDomains.contains(domain) { | ||
| 617 | selectedDomains.remove(domain) | ||
| 618 | } else { | ||
| 619 | selectedDomains.insert(domain) | ||
| 620 | } | ||
| 621 | } | ||
| 622 | |||
| 623 | private func save() { | ||
| 624 | switch mode { | ||
| 625 | case .existing: | ||
| 626 | guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return } | ||
| 627 | viewModel.addDomains(selectedDomainList, to: workflow) | ||
| 628 | case .new: | ||
| 629 | _ = viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) | ||
| 630 | } | ||
| 631 | |||
| 632 | dismiss() | ||
| 633 | } | ||
| 634 | } | ||