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 @@
354354 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
355355 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
356356 CODE_SIGN_STYLE = Automatic;
357 CURRENT_PROJECT_VERSION = 20;
357 CURRENT_PROJECT_VERSION = 21;
358358 DEVELOPMENT_TEAM = ZCNAX3VL9D;
359359 ENABLE_PREVIEWS = YES;
360360 GENERATE_INFOPLIST_FILE = YES;
@@ -371,7 +371,7 @@
371371 "$(inherited)",
372372 "@executable_path/Frameworks",
373373 );
374 MARKETING_VERSION = 2.7.0;
374 MARKETING_VERSION = 2.8.0;
375375 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
376376 PRODUCT_NAME = "$(TARGET_NAME)";
377377 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -390,7 +390,7 @@
390390 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
391391 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
392392 CODE_SIGN_STYLE = Automatic;
393 CURRENT_PROJECT_VERSION = 20;
393 CURRENT_PROJECT_VERSION = 21;
394394 DEVELOPMENT_TEAM = ZCNAX3VL9D;
395395 ENABLE_PREVIEWS = YES;
396396 GENERATE_INFOPLIST_FILE = YES;
@@ -407,7 +407,7 @@
407407 "$(inherited)",
408408 "@executable_path/Frameworks",
409409 );
410 MARKETING_VERSION = 2.7.0;
410 MARKETING_VERSION = 2.8.0;
411411 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
412412 PRODUCT_NAME = "$(TARGET_NAME)";
413413 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
22
33enum AppVersion {
44 static var current: String {
5 "2.7.0"
5 "2.8.0"
66 }
77}
DomainDig/ContentView.swift +112
@@ -18,6 +18,10 @@ enum ResultSection: String, Hashable {
1818 case subdomains
1919}
2020
21private struct WorkflowNavigationTarget: Hashable {
22 let workflowID: UUID
23}
24
2125struct ContentView: View {
2226 @Environment(\.appDensity) private var appDensity
2327 @State private var viewModel = DomainViewModel()
@@ -30,6 +34,8 @@ struct ContentView: View {
3034 @State private var showTrackLimitAlert = false
3135 @State private var inputMode: LookupInputMode = .single
3236 @State private var collapsedSections: Set<ResultSection> = [.network]
37 @State private var showingCurrentDomainWorkflowSheet = false
38 @State private var showingBatchWorkflowSheet = false
3339
3440 var body: some View {
3541 NavigationStack(path: $navigationPath) {
@@ -68,6 +74,7 @@ struct ContentView: View {
6874 confidence: viewModel.currentSnapshot.availabilityConfidence,
6975 snapshotNote: viewModel.currentSnapshot.note,
7076 trackedDomain: viewModel.currentTrackedDomain,
77 workflows: viewModel.currentDomainWorkflows,
7178 trackingLimitMessage: viewModel.trackingLimitMessage,
7279 onTrack: {
7380 if !viewModel.trackCurrentDomain() {
@@ -82,6 +89,15 @@ struct ContentView: View {
8289 guard let trackedDomain = viewModel.currentTrackedDomain else { return }
8390 trackingNoteDraft = trackedDomain.note ?? ""
8491 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)
85101 }
86102 )
87103 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -224,6 +240,12 @@ struct ContentView: View {
224240 Label("Saved Domains", systemImage: "bookmark")
225241 }
226242
243 NavigationLink {
244 WorkflowsView(viewModel: viewModel)
245 } label: {
246 Label("Workflows", systemImage: "square.stack.3d.down.right")
247 }
248
227249 NavigationLink {
228250 SettingsView(viewModel: viewModel)
229251 } label: {
@@ -235,6 +257,9 @@ struct ContentView: View {
235257 }
236258 }
237259 }
260 .navigationDestination(for: WorkflowNavigationTarget.self) { target in
261 WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID)
262 }
238263 }
239264 .onAppear {
240265 domainFieldFocused = true
@@ -277,6 +302,20 @@ struct ContentView: View {
277302 }
278303 }
279304 }
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 }
280319 }
281320
282321 private var inputSection: some View {
@@ -353,6 +392,30 @@ struct ContentView: View {
353392 HStack {
354393 Spacer()
355394 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 }
356419 Button {
357420 viewModel.toggleSavedDomain()
358421 } label: {
@@ -392,6 +455,10 @@ struct ContentView: View {
392455 }
393456 if !viewModel.currentBatchResultEntries.isEmpty {
394457 Menu {
458 Button("Add to Workflow") {
459 showingBatchWorkflowSheet = true
460 }
461 Divider()
395462 Button("Export Batch TXT") {
396463 shareBatchResults(format: .text)
397464 }
@@ -982,10 +1049,14 @@ struct DomainSectionView: View {
9821049 let confidence: ConfidenceLevel?
9831050 let snapshotNote: String?
9841051 let trackedDomain: TrackedDomain?
1052 let workflows: [DomainWorkflow]
9851053 let trackingLimitMessage: String?
9861054 let onTrack: () -> Void
9871055 let onTogglePinned: () -> Void
9881056 let onEditNote: (() -> Void)?
1057 let onAddToWorkflow: (() -> Void)?
1058 let onOpenWorkflow: ((DomainWorkflow) -> Void)?
1059 let onRunWorkflow: ((DomainWorkflow) -> Void)?
9891060
9901061 var body: some View {
9911062 CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
@@ -1032,6 +1103,47 @@ struct DomainSectionView: View {
10321103 MessageRowView(text: trackingLimitMessage, isError: false)
10331104 .padding(.top, 4)
10341105 }
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 }
10351147 if availabilityLoading {
10361148 ProgressView("Checking availability…")
10371149 .appLoadingStyle()
DomainDig/DomainViewModel.swift +202 −5
@@ -175,6 +175,7 @@ final class DomainViewModel {
175175 private(set) var batchTotalCount = 0
176176 private(set) var batchLookupRunning = false
177177 var latestBatchSweepSummary: BatchSweepSummary?
178 var latestWorkflowRunSummary: WorkflowRunSummary?
178179 private(set) var notificationsAuthorized = false
179180
180181 private var lookupTask: Task<Void, Never>?
@@ -184,6 +185,8 @@ final class DomainViewModel {
184185 private var lookupStartedAt: Date?
185186 private var activeBatchDomains: [String] = []
186187 private var lastBatchStartedAt: Date?
188 private var activeWorkflowRunID: UUID?
189 private var activeWorkflowRunName: String?
187190 private let reportBuilder = DomainReportBuilder()
188191 private let inspectionService = DomainInspectionService()
189192 private(set) var currentResultSource: LookupResultSource = .live
@@ -206,6 +209,8 @@ final class DomainViewModel {
206209 private static let historyKey = "lookupHistory"
207210 private static let maxHistory = 250
208211 var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries()
212 private static let workflowsKey = "domainWorkflows"
213 var workflows: [DomainWorkflow] = DomainViewModel.loadWorkflows()
209214 var historySearchText = ""
210215 var historyDateFilter: HistoryDateFilter = .all
211216 var historyChangeFilter: ChangeFilterOption = .all
@@ -327,6 +332,9 @@ final class DomainViewModel {
327332
328333 var batchProgressLabel: String {
329334 guard batchTotalCount > 0 else { return "No active batch" }
335 if !batchLookupRunning, batchCompletedCount >= batchTotalCount {
336 return "\(batchCompletedCount)/\(batchTotalCount) • Complete"
337 }
330338 let domainLabel = activeBatchDomains.first ?? batchCurrentDomain ?? "Preparing"
331339 return "\(batchCompletedCount)/\(batchTotalCount) • \(domainLabel)"
332340 }
@@ -343,6 +351,11 @@ final class DomainViewModel {
343351 return trackedDomain(for: searchedDomain)
344352 }
345353
354 var currentDomainWorkflows: [DomainWorkflow] {
355 guard !searchedDomain.isEmpty else { return [] }
356 return workflowsContaining(domain: searchedDomain)
357 }
358
346359 var isCurrentDomainTracked: Bool {
347360 currentTrackedDomain != nil
348361 }
@@ -655,6 +668,12 @@ final class DomainViewModel {
655668 rerunNavigationToken = UUID()
656669 }
657670
671 func openInspection(for domain: String) {
672 self.domain = normalizedDomain(domain)
673 run()
674 rerunNavigationToken = UUID()
675 }
676
658677 func reset() {
659678 lookupTask?.cancel()
660679 customPortScanTask?.cancel()
@@ -717,6 +736,101 @@ final class DomainViewModel {
717736 }
718737 }
719738
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
720834 func runCustomPortScan(ports: [UInt16]) async {
721835 guard !searchedDomain.isEmpty else {
722836 customPortScanError = "Run a domain lookup first"
@@ -760,6 +874,11 @@ final class DomainViewModel {
760874 return try? DomainReportExporter.data(for: currentReport, format: .json)
761875 }
762876
877 func exportJSONString() -> String? {
878 guard let data = exportJSONData() else { return nil }
879 return String(data: data, encoding: .utf8)
880 }
881
763882 func exportBatchText() -> String {
764883 DomainReportExporter.batchText(
765884 for: currentBatchReports(),
@@ -798,6 +917,25 @@ final class DomainViewModel {
798917 )
799918 }
800919
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
801939 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
802940 let previous = previousSnapshot(
803941 for: domain,
@@ -1533,7 +1671,7 @@ final class DomainViewModel {
15331671 return lookupID
15341672 }
15351673
1536 private func startBatchLookup(domains: [String], source: BatchLookupSource) {
1674 private func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) {
15371675 guard !domains.isEmpty else { return }
15381676 guard !batchLookupRunning else { return }
15391677
@@ -1545,6 +1683,8 @@ final class DomainViewModel {
15451683 lastBatchStartedAt = now
15461684 clearBatchState()
15471685 batchLookupSource = source
1686 activeWorkflowRunID = workflow?.id
1687 activeWorkflowRunName = workflow?.name
15481688 batchTotalCount = domains.count
15491689 batchLookupRunning = true
15501690 batchResults = domains.map {
@@ -1578,7 +1718,10 @@ final class DomainViewModel {
15781718 batchTotalCount = 0
15791719 batchLookupRunning = false
15801720 latestBatchSweepSummary = nil
1721 latestWorkflowRunSummary = nil
15811722 activeBatchDomains = []
1723 activeWorkflowRunID = nil
1724 activeWorkflowRunName = nil
15821725 batchTask = nil
15831726 }
15841727
@@ -1691,12 +1834,16 @@ final class DomainViewModel {
16911834 refreshingTrackedDomainID = nil
16921835 batchTask = nil
16931836
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
16941841 let summary = BatchSweepSummary(
16951842 source: source,
16961843 totalDomains: batchResults.count,
1697 changedDomains: batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count,
1698 unchangedDomains: batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count,
1699 warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count,
1844 changedDomains: changedCount,
1845 unchangedDomains: unchangedCount,
1846 warningDomains: warningCount,
17001847 results: batchResults.sorted { lhs, rhs in
17011848 if lhs.status != rhs.status {
17021849 return lhs.status.rawValue < rhs.status.rawValue
@@ -1707,7 +1854,20 @@ final class DomainViewModel {
17071854 )
17081855 latestBatchSweepSummary = summary
17091856
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 {
17111871 Task {
17121872 await LocalNotificationService.shared.notifySweepComplete(summary: summary)
17131873 }
@@ -1895,6 +2055,14 @@ final class DomainViewModel {
18952055 currentBatchResultEntries.map(report(for:))
18962056 }
18972057
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
18982066 private func reports(for domains: [TrackedDomain]) -> [DomainReport] {
18992067 let latestEntries = latestSnapshots(for: domains)
19002068
@@ -2024,6 +2192,27 @@ final class DomainViewModel {
20242192 return []
20252193 }
20262194
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
20272216 private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
20282217 var seen = Set<String>()
20292218 return domains.filter { domain in
@@ -2032,6 +2221,14 @@ final class DomainViewModel {
20322221 }
20332222 }
20342223
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
20352232 private func historySortPredicate(lhs: HistoryEntry, rhs: HistoryEntry) -> Bool {
20362233 switch historySortOption {
20372234 case .newest:
DomainDig/HistoryView.swift +19 −1
@@ -5,6 +5,7 @@ struct HistoryView: View {
55 @Bindable var viewModel: DomainViewModel
66 @Environment(\.dismiss) private var dismiss
77 @State private var showClearAllConfirmation = false
8 @State private var showWorkflowAddSheet = false
89
910 private var groupedHistory: [HistoryGroup] {
1011 HistoryGroup.groups(for: viewModel.filteredHistory)
@@ -105,6 +106,12 @@ struct HistoryView: View {
105106 Button("Clear All", role: .destructive) {
106107 showClearAllConfirmation = true
107108 }
109
110 if !viewModel.filteredHistory.isEmpty {
111 Button("Add to Workflow") {
112 showWorkflowAddSheet = true
113 }
114 }
108115 } label: {
109116 Image(systemName: "line.3.horizontal.decrease.circle")
110117 }
@@ -121,6 +128,13 @@ struct HistoryView: View {
121128 } message: {
122129 Text("This will delete all saved history entries. This cannot be undone.")
123130 }
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 }
124138 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
125139 dismiss()
126140 }
@@ -170,6 +184,7 @@ struct HistoryDetailView: View {
170184 confidence: snapshot.availabilityConfidence,
171185 snapshotNote: entry.note,
172186 trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }),
187 workflows: viewModel.workflowsContaining(domain: entry.domain),
173188 trackingLimitMessage: nil,
174189 onTrack: {
175190 _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
@@ -178,7 +193,10 @@ struct HistoryDetailView: View {
178193 guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return }
179194 viewModel.togglePinned(for: trackedDomain)
180195 },
181 onEditNote: nil
196 onEditNote: nil,
197 onAddToWorkflow: nil,
198 onOpenWorkflow: nil,
199 onRunWorkflow: nil
182200 )
183201 .padding(.top, appDensity.metrics.sectionSpacing)
184202 OwnershipSectionView(
DomainDig/Models.swift +45
@@ -223,6 +223,7 @@ struct DomainChangeSummary: Codable, Equatable {
223223enum BatchLookupSource: String, Codable {
224224 case manual
225225 case watchlistRefresh
226 case workflow
226227}
227228
228229enum BatchLookupStatus: String, Codable {
@@ -276,6 +277,13 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
276277 self.status = status
277278 self.errorMessage = errorMessage
278279 }
280
281 var hasMeaningfulChange: Bool {
282 quickStatus == "Changed"
283 || quickStatus == "High"
284 || certificateWarningLevel != .none
285 || status == .failed
286 }
279287}
280288
281289struct BatchSweepSummary: Identifiable, Equatable {
@@ -289,6 +297,43 @@ struct BatchSweepSummary: Identifiable, Equatable {
289297 let generatedAt: Date
290298}
291299
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
292337enum DataCapability: String, Codable {
293338 case ownershipHistory
294339 case dnsHistory
DomainDig/WatchlistView.swift +12
@@ -4,6 +4,7 @@ struct WatchlistView: View {
44 @Environment(\.appDensity) private var appDensity
55 @Bindable var viewModel: DomainViewModel
66 @Environment(\.dismiss) private var dismiss
7 @State private var showWorkflowAddSheet = false
78
89 private var pinnedDomains: [TrackedDomain] {
910 viewModel.filteredTrackedDomains.filter(\.isPinned)
@@ -99,6 +100,10 @@ struct WatchlistView: View {
99100 }
100101 .disabled(viewModel.batchLookupRunning)
101102
103 Button("Add to Workflow") {
104 showWorkflowAddSheet = true
105 }
106
102107 Button("Export TXT") {
103108 shareTrackedDomains(format: .text)
104109 }
@@ -124,6 +129,13 @@ struct WatchlistView: View {
124129 .sheet(item: batchSummaryBinding) { summary in
125130 BatchSweepSummaryView(viewModel: viewModel, summary: summary)
126131 }
132 .sheet(isPresented: $showWorkflowAddSheet) {
133 WorkflowBulkAddSheet(
134 viewModel: viewModel,
135 title: "Add Watchlist Domains",
136 availableDomains: viewModel.filteredTrackedDomains.map(\.domain)
137 )
138 }
127139 .preferredColorScheme(.dark)
128140 }
129141
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}