Commit 9384a668bf

9384a668bf36f23f71e25d05b490d1040d308845

parent: 7b41195620

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-22 15:14 UTC

feat(v2.8.0): add workflows and repeatable inspection automation

* introduce DomainWorkflow model and persistence
* add WorkflowsView and execution flow
* support batch inspection via workflows
* add workflow-based export (TXT/CSV/JSON)
* enable bulk add from history/watchlist
* add quick actions for common operations
* integrate workflows with tracking and history

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
3enum AppVersion { 3enum 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
21private struct WorkflowNavigationTarget: Hashable {
22 let workflowID: UUID
23}
24
21struct ContentView: View { 25struct 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 {
223enum BatchLookupSource: String, Codable { 223enum BatchLookupSource: String, Codable {
224 case manual 224 case manual
225 case watchlistRefresh 225 case watchlistRefresh
226 case workflow
226} 227}
227 228
228enum BatchLookupStatus: String, Codable { 229enum 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
281struct BatchSweepSummary: Identifiable, Equatable { 289struct BatchSweepSummary: Identifiable, Equatable {
@@ -289,6 +297,43 @@ struct BatchSweepSummary: Identifiable, Equatable {
289 let generatedAt: Date 297 let generatedAt: Date
290} 298}
291 299
300struct 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
325struct 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
292enum DataCapability: String, Codable { 337enum 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 @@
1import SwiftUI
2
3private enum WorkflowDestinationMode: String, CaseIterable, Identifiable {
4 case existing
5 case new
6
7 var id: String { rawValue }
8}
9
10struct 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
107private 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
141struct 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
340struct 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
408struct 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
518struct 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}