summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-22 10:14:30 -0500
committerChristian Cleberg <[email protected]>2026-04-22 10:14:30 -0500
commit9384a668bf36f23f71e25d05b490d1040d308845 (patch)
tree6681f4b262a9414c7fc96148652599215b7973fc
parent7b41195620eadd54d25fa98dcd36735cba906ba3 (diff)
downloaddomain-dig-9384a668bf36f23f71e25d05b490d1040d308845.tar.gz
domain-dig-9384a668bf36f23f71e25d05b490d1040d308845.tar.bz2
domain-dig-9384a668bf36f23f71e25d05b490d1040d308845.zip
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
-rw-r--r--DomainDig.xcodeproj/project.pbxproj8
-rw-r--r--DomainDig/AppVersion.swift2
-rw-r--r--DomainDig/ContentView.swift112
-rw-r--r--DomainDig/DomainViewModel.swift207
-rw-r--r--DomainDig/HistoryView.swift20
-rw-r--r--DomainDig/Models.swift45
-rw-r--r--DomainDig/WatchlistView.swift12
-rw-r--r--DomainDig/WorkflowsView.swift634
8 files changed, 1029 insertions, 11 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 3e7f5d6..345e575 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -354,7 +354,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 20;
+ CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -371,7 +371,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.7.0;
+ MARKETING_VERSION = 2.8.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -390,7 +390,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 20;
+ CURRENT_PROJECT_VERSION = 21;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -407,7 +407,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.7.0;
+ MARKETING_VERSION = 2.8.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift
index a1516b8..fb7481e 100644
--- a/DomainDig/AppVersion.swift
+++ b/DomainDig/AppVersion.swift
@@ -2,6 +2,6 @@ import Foundation
enum AppVersion {
static var current: String {
- "2.7.0"
+ "2.8.0"
}
}
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index ed2da49..b023547 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -18,6 +18,10 @@ enum ResultSection: String, Hashable {
case subdomains
}
+private struct WorkflowNavigationTarget: Hashable {
+ let workflowID: UUID
+}
+
struct ContentView: View {
@Environment(\.appDensity) private var appDensity
@State private var viewModel = DomainViewModel()
@@ -30,6 +34,8 @@ struct ContentView: View {
@State private var showTrackLimitAlert = false
@State private var inputMode: LookupInputMode = .single
@State private var collapsedSections: Set<ResultSection> = [.network]
+ @State private var showingCurrentDomainWorkflowSheet = false
+ @State private var showingBatchWorkflowSheet = false
var body: some View {
NavigationStack(path: $navigationPath) {
@@ -68,6 +74,7 @@ struct ContentView: View {
confidence: viewModel.currentSnapshot.availabilityConfidence,
snapshotNote: viewModel.currentSnapshot.note,
trackedDomain: viewModel.currentTrackedDomain,
+ workflows: viewModel.currentDomainWorkflows,
trackingLimitMessage: viewModel.trackingLimitMessage,
onTrack: {
if !viewModel.trackCurrentDomain() {
@@ -82,6 +89,15 @@ struct ContentView: View {
guard let trackedDomain = viewModel.currentTrackedDomain else { return }
trackingNoteDraft = trackedDomain.note ?? ""
editingTrackedDomain = trackedDomain
+ },
+ onAddToWorkflow: {
+ showingCurrentDomainWorkflowSheet = true
+ },
+ onOpenWorkflow: { workflow in
+ navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id))
+ },
+ onRunWorkflow: { workflow in
+ viewModel.rerunCurrentDomain(in: workflow)
}
)
.padding(.top, appDensity.metrics.sectionSpacing)
@@ -225,6 +241,12 @@ struct ContentView: View {
}
NavigationLink {
+ WorkflowsView(viewModel: viewModel)
+ } label: {
+ Label("Workflows", systemImage: "square.stack.3d.down.right")
+ }
+
+ NavigationLink {
SettingsView(viewModel: viewModel)
} label: {
Label("Settings", systemImage: "gearshape")
@@ -235,6 +257,9 @@ struct ContentView: View {
}
}
}
+ .navigationDestination(for: WorkflowNavigationTarget.self) { target in
+ WorkflowDetailView(viewModel: viewModel, workflowID: target.workflowID)
+ }
}
.onAppear {
domainFieldFocused = true
@@ -277,6 +302,20 @@ struct ContentView: View {
}
}
}
+ .sheet(isPresented: $showingCurrentDomainWorkflowSheet) {
+ WorkflowBulkAddSheet(
+ viewModel: viewModel,
+ title: "Add Domain to Workflow",
+ availableDomains: [viewModel.searchedDomain]
+ )
+ }
+ .sheet(isPresented: $showingBatchWorkflowSheet) {
+ WorkflowBulkAddSheet(
+ viewModel: viewModel,
+ title: "Add Batch Domains",
+ availableDomains: viewModel.batchResults.map(\.domain)
+ )
+ }
}
private var inputSection: some View {
@@ -353,6 +392,30 @@ struct ContentView: View {
HStack {
Spacer()
if viewModel.resultsLoaded {
+ Menu {
+ if !viewModel.isCurrentDomainTracked {
+ Button("Track this domain") {
+ if !viewModel.trackCurrentDomain() {
+ showTrackLimitAlert = true
+ }
+ }
+ }
+ Button("Add to workflow") {
+ showingCurrentDomainWorkflowSheet = true
+ }
+ Button("Copy report JSON") {
+ guard let json = viewModel.exportJSONString() else { return }
+ AppClipboard.copy(json)
+ AppHaptics.copy()
+ }
+ Button("Export report") {
+ shareSingleResults(format: .json)
+ }
+ } label: {
+ Image(systemName: "bolt.circle")
+ .font(appDensity.font(.body, design: .default))
+ .foregroundStyle(.secondary)
+ }
Button {
viewModel.toggleSavedDomain()
} label: {
@@ -392,6 +455,10 @@ struct ContentView: View {
}
if !viewModel.currentBatchResultEntries.isEmpty {
Menu {
+ Button("Add to Workflow") {
+ showingBatchWorkflowSheet = true
+ }
+ Divider()
Button("Export Batch TXT") {
shareBatchResults(format: .text)
}
@@ -982,10 +1049,14 @@ struct DomainSectionView: View {
let confidence: ConfidenceLevel?
let snapshotNote: String?
let trackedDomain: TrackedDomain?
+ let workflows: [DomainWorkflow]
let trackingLimitMessage: String?
let onTrack: () -> Void
let onTogglePinned: () -> Void
let onEditNote: (() -> Void)?
+ let onAddToWorkflow: (() -> Void)?
+ let onOpenWorkflow: ((DomainWorkflow) -> Void)?
+ let onRunWorkflow: ((DomainWorkflow) -> Void)?
var body: some View {
CollapsibleSectionView(title: "Domain", isCollapsed: $isCollapsed) {
@@ -1032,6 +1103,47 @@ struct DomainSectionView: View {
MessageRowView(text: trackingLimitMessage, isError: false)
.padding(.top, 4)
}
+ if let onAddToWorkflow {
+ Button {
+ onAddToWorkflow()
+ } label: {
+ Label("Add to workflow", systemImage: "plus.rectangle.on.folder")
+ .font(appDensity.font(.caption))
+ }
+ .buttonStyle(.bordered)
+ .padding(.top, 4)
+ }
+ if !workflows.isEmpty {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Part of workflow")
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+
+ ForEach(workflows) { workflow in
+ HStack {
+ Text(workflow.name)
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.primary)
+ Spacer()
+ if let onOpenWorkflow {
+ Button("Open") {
+ onOpenWorkflow(workflow)
+ }
+ .buttonStyle(.bordered)
+ .font(appDensity.font(.caption2))
+ }
+ if let onRunWorkflow {
+ Button("Run") {
+ onRunWorkflow(workflow)
+ }
+ .buttonStyle(.bordered)
+ .font(appDensity.font(.caption2))
+ }
+ }
+ }
+ }
+ .padding(.top, 4)
+ }
if availabilityLoading {
ProgressView("Checking availability…")
.appLoadingStyle()
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 3373447..f9a6523 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -175,6 +175,7 @@ final class DomainViewModel {
private(set) var batchTotalCount = 0
private(set) var batchLookupRunning = false
var latestBatchSweepSummary: BatchSweepSummary?
+ var latestWorkflowRunSummary: WorkflowRunSummary?
private(set) var notificationsAuthorized = false
private var lookupTask: Task<Void, Never>?
@@ -184,6 +185,8 @@ final class DomainViewModel {
private var lookupStartedAt: Date?
private var activeBatchDomains: [String] = []
private var lastBatchStartedAt: Date?
+ private var activeWorkflowRunID: UUID?
+ private var activeWorkflowRunName: String?
private let reportBuilder = DomainReportBuilder()
private let inspectionService = DomainInspectionService()
private(set) var currentResultSource: LookupResultSource = .live
@@ -206,6 +209,8 @@ final class DomainViewModel {
private static let historyKey = "lookupHistory"
private static let maxHistory = 250
var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries()
+ private static let workflowsKey = "domainWorkflows"
+ var workflows: [DomainWorkflow] = DomainViewModel.loadWorkflows()
var historySearchText = ""
var historyDateFilter: HistoryDateFilter = .all
var historyChangeFilter: ChangeFilterOption = .all
@@ -327,6 +332,9 @@ final class DomainViewModel {
var batchProgressLabel: String {
guard batchTotalCount > 0 else { return "No active batch" }
+ if !batchLookupRunning, batchCompletedCount >= batchTotalCount {
+ return "\(batchCompletedCount)/\(batchTotalCount) • Complete"
+ }
let domainLabel = activeBatchDomains.first ?? batchCurrentDomain ?? "Preparing"
return "\(batchCompletedCount)/\(batchTotalCount) • \(domainLabel)"
}
@@ -343,6 +351,11 @@ final class DomainViewModel {
return trackedDomain(for: searchedDomain)
}
+ var currentDomainWorkflows: [DomainWorkflow] {
+ guard !searchedDomain.isEmpty else { return [] }
+ return workflowsContaining(domain: searchedDomain)
+ }
+
var isCurrentDomainTracked: Bool {
currentTrackedDomain != nil
}
@@ -655,6 +668,12 @@ final class DomainViewModel {
rerunNavigationToken = UUID()
}
+ func openInspection(for domain: String) {
+ self.domain = normalizedDomain(domain)
+ run()
+ rerunNavigationToken = UUID()
+ }
+
func reset() {
lookupTask?.cancel()
customPortScanTask?.cancel()
@@ -717,6 +736,101 @@ final class DomainViewModel {
}
}
+ func workflow(withID id: UUID) -> DomainWorkflow? {
+ workflows.first(where: { $0.id == id })
+ }
+
+ func workflowsContaining(domain: String) -> [DomainWorkflow] {
+ let normalized = normalizedDomain(domain)
+ guard !normalized.isEmpty else { return [] }
+ return workflows.filter { workflow in
+ workflow.domains.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame })
+ }
+ }
+
+ @discardableResult
+ func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? {
+ let normalizedDomains = normalizedDomains(domains)
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
+
+ let workflow = DomainWorkflow(
+ name: trimmedName,
+ domains: normalizedDomains,
+ createdAt: Date(),
+ updatedAt: Date(),
+ notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+ )
+ workflows.insert(workflow, at: 0)
+ persistWorkflows()
+ return workflow
+ }
+
+ func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) {
+ guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
+ let normalizedDomains = normalizedDomains(domains)
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return }
+
+ workflows[index].name = trimmedName
+ workflows[index].domains = normalizedDomains
+ workflows[index].notes = notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
+ workflows[index].updatedAt = Date()
+ persistWorkflows()
+ }
+
+ func deleteWorkflow(_ workflow: DomainWorkflow) {
+ workflows.removeAll { $0.id == workflow.id }
+ if latestWorkflowRunSummary?.workflowID == workflow.id {
+ latestWorkflowRunSummary = nil
+ }
+ if activeWorkflowRunID == workflow.id {
+ activeWorkflowRunID = nil
+ activeWorkflowRunName = nil
+ }
+ persistWorkflows()
+ }
+
+ func addDomains(_ domains: [String], to workflow: DomainWorkflow) {
+ guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
+ let mergedDomains = normalizedDomains(workflows[index].domains + domains)
+ guard mergedDomains != workflows[index].domains else { return }
+ workflows[index].domains = mergedDomains
+ workflows[index].updatedAt = Date()
+ persistWorkflows()
+ }
+
+ func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) {
+ guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
+ workflows[index].domains.remove(atOffsets: offsets)
+ workflows[index].updatedAt = Date()
+ persistWorkflows()
+ }
+
+ func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) {
+ guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
+ workflows[index].domains.move(fromOffsets: offsets, toOffset: destination)
+ workflows[index].updatedAt = Date()
+ persistWorkflows()
+ }
+
+ func runWorkflow(_ workflow: DomainWorkflow) {
+ guard !workflow.domains.isEmpty else { return }
+ startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
+ }
+
+ func rerunCurrentDomain(in workflow: DomainWorkflow) {
+ guard workflow.domains.contains(where: { $0.caseInsensitiveCompare(searchedDomain) == .orderedSame }) else {
+ return
+ }
+ runWorkflow(workflow)
+ }
+
+ func refreshWorkflowList() async {
+ workflows = Self.loadWorkflows()
+ await Task.yield()
+ }
+
func runCustomPortScan(ports: [UInt16]) async {
guard !searchedDomain.isEmpty else {
customPortScanError = "Run a domain lookup first"
@@ -760,6 +874,11 @@ final class DomainViewModel {
return try? DomainReportExporter.data(for: currentReport, format: .json)
}
+ func exportJSONString() -> String? {
+ guard let data = exportJSONData() else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+
func exportBatchText() -> String {
DomainReportExporter.batchText(
for: currentBatchReports(),
@@ -798,6 +917,25 @@ final class DomainViewModel {
)
}
+ func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
+ DomainReportExporter.batchText(
+ for: workflowReports(from: summary, changedOnly: changedOnly),
+ title: "\(summary.workflowName) Workflow Export"
+ )
+ }
+
+ func exportWorkflowCSV(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
+ DomainReportExporter.csv(for: workflowReports(from: summary, changedOnly: changedOnly))
+ }
+
+ func exportWorkflowJSONData(summary: WorkflowRunSummary, changedOnly: Bool) -> Data? {
+ try? DomainReportExporter.data(
+ for: workflowReports(from: summary, changedOnly: changedOnly),
+ format: .json,
+ title: "\(summary.workflowName) Workflow Export"
+ )
+ }
+
private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
let previous = previousSnapshot(
for: domain,
@@ -1533,7 +1671,7 @@ final class DomainViewModel {
return lookupID
}
- private func startBatchLookup(domains: [String], source: BatchLookupSource) {
+ private func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) {
guard !domains.isEmpty else { return }
guard !batchLookupRunning else { return }
@@ -1545,6 +1683,8 @@ final class DomainViewModel {
lastBatchStartedAt = now
clearBatchState()
batchLookupSource = source
+ activeWorkflowRunID = workflow?.id
+ activeWorkflowRunName = workflow?.name
batchTotalCount = domains.count
batchLookupRunning = true
batchResults = domains.map {
@@ -1578,7 +1718,10 @@ final class DomainViewModel {
batchTotalCount = 0
batchLookupRunning = false
latestBatchSweepSummary = nil
+ latestWorkflowRunSummary = nil
activeBatchDomains = []
+ activeWorkflowRunID = nil
+ activeWorkflowRunName = nil
batchTask = nil
}
@@ -1691,12 +1834,16 @@ final class DomainViewModel {
refreshingTrackedDomainID = nil
batchTask = nil
+ let changedCount = batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count
+ let unchangedCount = batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count
+ let warningCount = batchResults.filter { $0.certificateWarningLevel != .none }.count
+
let summary = BatchSweepSummary(
source: source,
totalDomains: batchResults.count,
- changedDomains: batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count,
- unchangedDomains: batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count,
- warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count,
+ changedDomains: changedCount,
+ unchangedDomains: unchangedCount,
+ warningDomains: warningCount,
results: batchResults.sorted { lhs, rhs in
if lhs.status != rhs.status {
return lhs.status.rawValue < rhs.status.rawValue
@@ -1707,7 +1854,20 @@ final class DomainViewModel {
)
latestBatchSweepSummary = summary
- if notificationsAuthorized {
+ if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
+ latestWorkflowRunSummary = WorkflowRunSummary(
+ workflowID: activeWorkflowRunID,
+ workflowName: activeWorkflowRunName,
+ totalDomains: batchResults.count,
+ changedDomains: changedCount,
+ unchangedDomains: unchangedCount,
+ warningDomains: warningCount,
+ results: summary.results,
+ generatedAt: summary.generatedAt
+ )
+ }
+
+ if notificationsAuthorized, source != .workflow {
Task {
await LocalNotificationService.shared.notifySweepComplete(summary: summary)
}
@@ -1895,6 +2055,14 @@ final class DomainViewModel {
currentBatchResultEntries.map(report(for:))
}
+ private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] {
+ let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results
+ return filteredResults.compactMap { result in
+ guard let entry = historyEntry(for: result) else { return nil }
+ return report(for: entry)
+ }
+ }
+
private func reports(for domains: [TrackedDomain]) -> [DomainReport] {
let latestEntries = latestSnapshots(for: domains)
@@ -2024,6 +2192,27 @@ final class DomainViewModel {
return []
}
+ private func persistWorkflows() {
+ if let data = try? JSONEncoder().encode(workflows) {
+ UserDefaults.standard.set(data, forKey: Self.workflowsKey)
+ }
+ }
+
+ private static func loadWorkflows() -> [DomainWorkflow] {
+ let defaults = UserDefaults.standard
+ guard let data = defaults.data(forKey: workflowsKey),
+ let workflows = try? JSONDecoder().decode([DomainWorkflow].self, from: data) else {
+ return []
+ }
+
+ return workflows.sorted { lhs, rhs in
+ if lhs.updatedAt != rhs.updatedAt {
+ return lhs.updatedAt > rhs.updatedAt
+ }
+ return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
+ }
+ }
+
private static func deduplicatedTrackedDomains(_ domains: [TrackedDomain]) -> [TrackedDomain] {
var seen = Set<String>()
return domains.filter { domain in
@@ -2032,6 +2221,14 @@ final class DomainViewModel {
}
}
+ private func normalizedDomains(_ domains: [String]) -> [String] {
+ var seen = Set<String>()
+ return domains
+ .map(normalizedDomain)
+ .filter { !$0.isEmpty }
+ .filter { seen.insert($0).inserted }
+ }
+
private func historySortPredicate(lhs: HistoryEntry, rhs: HistoryEntry) -> Bool {
switch historySortOption {
case .newest:
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index 9505c12..3bda59d 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -5,6 +5,7 @@ struct HistoryView: View {
@Bindable var viewModel: DomainViewModel
@Environment(\.dismiss) private var dismiss
@State private var showClearAllConfirmation = false
+ @State private var showWorkflowAddSheet = false
private var groupedHistory: [HistoryGroup] {
HistoryGroup.groups(for: viewModel.filteredHistory)
@@ -105,6 +106,12 @@ struct HistoryView: View {
Button("Clear All", role: .destructive) {
showClearAllConfirmation = true
}
+
+ if !viewModel.filteredHistory.isEmpty {
+ Button("Add to Workflow") {
+ showWorkflowAddSheet = true
+ }
+ }
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
@@ -121,6 +128,13 @@ struct HistoryView: View {
} message: {
Text("This will delete all saved history entries. This cannot be undone.")
}
+ .sheet(isPresented: $showWorkflowAddSheet) {
+ WorkflowBulkAddSheet(
+ viewModel: viewModel,
+ title: "Add History Domains",
+ availableDomains: Array(Set(viewModel.filteredHistory.map(\.domain))).sorted()
+ )
+ }
.onChange(of: viewModel.rerunNavigationToken) { _, _ in
dismiss()
}
@@ -170,6 +184,7 @@ struct HistoryDetailView: View {
confidence: snapshot.availabilityConfidence,
snapshotNote: entry.note,
trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }),
+ workflows: viewModel.workflowsContaining(domain: entry.domain),
trackingLimitMessage: nil,
onTrack: {
_ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
@@ -178,7 +193,10 @@ struct HistoryDetailView: View {
guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return }
viewModel.togglePinned(for: trackedDomain)
},
- onEditNote: nil
+ onEditNote: nil,
+ onAddToWorkflow: nil,
+ onOpenWorkflow: nil,
+ onRunWorkflow: nil
)
.padding(.top, appDensity.metrics.sectionSpacing)
OwnershipSectionView(
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 60e5852..0c2b4c2 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -223,6 +223,7 @@ struct DomainChangeSummary: Codable, Equatable {
enum BatchLookupSource: String, Codable {
case manual
case watchlistRefresh
+ case workflow
}
enum BatchLookupStatus: String, Codable {
@@ -276,6 +277,13 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
self.status = status
self.errorMessage = errorMessage
}
+
+ var hasMeaningfulChange: Bool {
+ quickStatus == "Changed"
+ || quickStatus == "High"
+ || certificateWarningLevel != .none
+ || status == .failed
+ }
}
struct BatchSweepSummary: Identifiable, Equatable {
@@ -289,6 +297,43 @@ struct BatchSweepSummary: Identifiable, Equatable {
let generatedAt: Date
}
+struct DomainWorkflow: Codable, Identifiable, Equatable {
+ let id: UUID
+ var name: String
+ var domains: [String]
+ var createdAt: Date
+ var updatedAt: Date
+ var notes: String?
+
+ init(
+ id: UUID = UUID(),
+ name: String,
+ domains: [String],
+ createdAt: Date = Date(),
+ updatedAt: Date = Date(),
+ notes: String? = nil
+ ) {
+ self.id = id
+ self.name = name
+ self.domains = domains
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ self.notes = notes
+ }
+}
+
+struct WorkflowRunSummary: Identifiable, Equatable {
+ let id = UUID()
+ let workflowID: UUID
+ let workflowName: String
+ let totalDomains: Int
+ let changedDomains: Int
+ let unchangedDomains: Int
+ let warningDomains: Int
+ let results: [BatchLookupResult]
+ let generatedAt: Date
+}
+
enum DataCapability: String, Codable {
case ownershipHistory
case dnsHistory
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index d58061f..a434705 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -4,6 +4,7 @@ struct WatchlistView: View {
@Environment(\.appDensity) private var appDensity
@Bindable var viewModel: DomainViewModel
@Environment(\.dismiss) private var dismiss
+ @State private var showWorkflowAddSheet = false
private var pinnedDomains: [TrackedDomain] {
viewModel.filteredTrackedDomains.filter(\.isPinned)
@@ -99,6 +100,10 @@ struct WatchlistView: View {
}
.disabled(viewModel.batchLookupRunning)
+ Button("Add to Workflow") {
+ showWorkflowAddSheet = true
+ }
+
Button("Export TXT") {
shareTrackedDomains(format: .text)
}
@@ -124,6 +129,13 @@ struct WatchlistView: View {
.sheet(item: batchSummaryBinding) { summary in
BatchSweepSummaryView(viewModel: viewModel, summary: summary)
}
+ .sheet(isPresented: $showWorkflowAddSheet) {
+ WorkflowBulkAddSheet(
+ viewModel: viewModel,
+ title: "Add Watchlist Domains",
+ availableDomains: viewModel.filteredTrackedDomains.map(\.domain)
+ )
+ }
.preferredColorScheme(.dark)
}
diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
new file mode 100644
index 0000000..ba88487
--- /dev/null
+++ b/DomainDig/WorkflowsView.swift
@@ -0,0 +1,634 @@
+import SwiftUI
+
+private enum WorkflowDestinationMode: String, CaseIterable, Identifiable {
+ case existing
+ case new
+
+ var id: String { rawValue }
+}
+
+struct WorkflowsView: View {
+ @Environment(\.appDensity) private var appDensity
+ @Bindable var viewModel: DomainViewModel
+
+ @State private var showingCreateWorkflow = false
+
+ var body: some View {
+ List {
+ if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
+ Section("Workflow Run") {
+ VStack(alignment: .leading, spacing: 8) {
+ ProgressView(
+ value: Double(viewModel.batchCompletedCount),
+ total: Double(max(viewModel.batchTotalCount, 1))
+ )
+ .tint(.cyan)
+
+ HStack {
+ Text(viewModel.batchProgressLabel)
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ Spacer()
+ if viewModel.batchLookupRunning {
+ Button("Cancel") {
+ viewModel.cancelBatchLookup()
+ }
+ .buttonStyle(.bordered)
+ .font(appDensity.font(.caption2))
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+
+ if viewModel.workflows.isEmpty {
+ Section {
+ EmptyStateCardView(
+ title: "No Workflows Yet",
+ message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
+ suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
+ systemImage: "square.stack.3d.down.right"
+ )
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ } else {
+ ForEach(viewModel.workflows) { workflow in
+ NavigationLink {
+ WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id)
+ } label: {
+ WorkflowRowView(workflow: workflow)
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+ .onDelete { offsets in
+ let workflows = offsets.map { viewModel.workflows[$0] }
+ workflows.forEach(viewModel.deleteWorkflow)
+ }
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .background(Color.black)
+ .refreshable {
+ await viewModel.refreshWorkflowList()
+ }
+ .navigationTitle("Workflows")
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ Button {
+ showingCreateWorkflow = true
+ } label: {
+ Image(systemName: "plus.circle")
+ }
+
+ if !viewModel.workflows.isEmpty {
+ EditButton()
+ }
+ }
+ }
+ .sheet(isPresented: $showingCreateWorkflow) {
+ WorkflowComposerView(viewModel: viewModel)
+ }
+ .sheet(item: workflowSummaryBinding) { summary in
+ WorkflowRunSummaryView(viewModel: viewModel, summary: summary)
+ }
+ .preferredColorScheme(.dark)
+ }
+
+ private var workflowSummaryBinding: Binding<WorkflowRunSummary?> {
+ Binding(
+ get: { viewModel.latestWorkflowRunSummary },
+ set: { viewModel.latestWorkflowRunSummary = $0 }
+ )
+ }
+}
+
+private struct WorkflowRowView: View {
+ @Environment(\.appDensity) private var appDensity
+
+ let workflow: DomainWorkflow
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(workflow.name)
+ .font(appDensity.font(.callout, design: .default, weight: .semibold))
+ .foregroundStyle(.primary)
+ .lineLimit(2)
+ Spacer(minLength: 8)
+ Text("\(workflow.domains.count) domains")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+
+ Text("Updated \(workflow.updatedAt.formatted(date: .abbreviated, time: .shortened))")
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+
+ if let notes = workflow.notes, !notes.isEmpty {
+ Text(notes)
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 4)
+ }
+}
+
+struct WorkflowDetailView: View {
+ @Environment(\.appDensity) private var appDensity
+ @Bindable var viewModel: DomainViewModel
+ let workflowID: UUID
+
+ @State private var showingEditor = false
+ @State private var draftDomain = ""
+ @State private var includeAllExports = false
+
+ private var workflow: DomainWorkflow? {
+ viewModel.workflow(withID: workflowID)
+ }
+
+ private var latestSummary: WorkflowRunSummary? {
+ guard viewModel.latestWorkflowRunSummary?.workflowID == workflowID else { return nil }
+ return viewModel.latestWorkflowRunSummary
+ }
+
+ var body: some View {
+ Group {
+ if let workflow {
+ List {
+ Section("Overview") {
+ statRow(label: "Domains", value: "\(workflow.domains.count)")
+ statRow(label: "Created", value: workflow.createdAt.formatted(date: .abbreviated, time: .shortened))
+ statRow(label: "Updated", value: workflow.updatedAt.formatted(date: .abbreviated, time: .shortened))
+ if let notes = workflow.notes, !notes.isEmpty {
+ Text(notes)
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Section("Actions") {
+ Button {
+ viewModel.runWorkflow(workflow)
+ } label: {
+ Label("Run Workflow", systemImage: "play.fill")
+ }
+ .disabled(workflow.domains.isEmpty || viewModel.batchLookupRunning)
+
+ if viewModel.batchLookupRunning, viewModel.batchLookupSource == .workflow {
+ Button("Cancel Workflow Run", role: .destructive) {
+ viewModel.cancelBatchLookup()
+ }
+ }
+
+ Button {
+ showingEditor = true
+ } label: {
+ Label("Edit Workflow", systemImage: "pencil")
+ }
+
+ if latestSummary != nil {
+ Toggle("Export all domains", isOn: $includeAllExports)
+ Menu {
+ Button("Export TXT") {
+ shareWorkflowResults(format: .text)
+ }
+ Button("Export CSV") {
+ shareWorkflowResults(format: .csv)
+ }
+ Button("Export JSON") {
+ shareWorkflowResults(format: .json)
+ }
+ } label: {
+ Label("Export Results", systemImage: "square.and.arrow.up")
+ }
+ }
+ }
+
+ if let latestSummary {
+ Section("Latest Run") {
+ statRow(label: "Processed", value: "\(latestSummary.totalDomains)")
+ statRow(label: "Changed", value: "\(latestSummary.changedDomains)")
+ statRow(label: "Warnings", value: "\(latestSummary.warningDomains)")
+ statRow(label: "Unchanged", value: "\(latestSummary.unchangedDomains)")
+
+ Button {
+ viewModel.latestWorkflowRunSummary = latestSummary
+ } label: {
+ Label("View Run Summary", systemImage: "list.bullet.rectangle")
+ }
+ }
+ }
+
+ Section("Add Domain") {
+ HStack {
+ TextField("example.com", text: $draftDomain)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Button("Add") {
+ guard !draftDomain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
+ viewModel.addDomains([draftDomain], to: workflow)
+ draftDomain = ""
+ }
+ }
+ }
+
+ Section("Domains") {
+ if workflow.domains.isEmpty {
+ Text("No domains in this workflow")
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(workflow.domains, id: \.self) { domain in
+ Button {
+ viewModel.openInspection(for: domain)
+ } label: {
+ HStack {
+ Text(domain)
+ .font(appDensity.font(.callout))
+ .foregroundStyle(.primary)
+ Spacer()
+ Image(systemName: "arrow.up.right.circle")
+ .foregroundStyle(.secondary)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ .onDelete { offsets in
+ viewModel.removeWorkflowDomains(at: offsets, from: workflow)
+ }
+ .onMove { offsets, destination in
+ viewModel.moveWorkflowDomains(from: offsets, to: destination, in: workflow)
+ }
+ }
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .background(Color.black)
+ .navigationTitle(workflow.name)
+ .toolbar {
+ if !workflow.domains.isEmpty {
+ EditButton()
+ }
+ }
+ .sheet(isPresented: $showingEditor) {
+ WorkflowComposerView(viewModel: viewModel, workflow: workflow)
+ }
+ .sheet(item: workflowSummaryBinding) { summary in
+ WorkflowRunSummaryView(viewModel: viewModel, summary: summary)
+ }
+ } else {
+ Text("Workflow not found")
+ .font(appDensity.font(.callout))
+ .foregroundStyle(.secondary)
+ }
+ }
+ .preferredColorScheme(.dark)
+ }
+
+ private func statRow(label: String, value: String) -> some View {
+ HStack {
+ Text(label)
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text(value)
+ .font(appDensity.font(.callout))
+ .foregroundStyle(.primary)
+ }
+ }
+
+ private var workflowSummaryBinding: Binding<WorkflowRunSummary?> {
+ Binding(
+ get: {
+ guard viewModel.latestWorkflowRunSummary?.workflowID == workflowID else { return nil }
+ return viewModel.latestWorkflowRunSummary
+ },
+ set: { _ in
+ viewModel.latestWorkflowRunSummary = nil
+ }
+ )
+ }
+
+ private func shareWorkflowResults(format: DomainExportFormat) {
+ guard let latestSummary else { return }
+
+ let changedOnly = !includeAllExports
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyyMMdd_HHmmss"
+ let timestamp = formatter.string(from: Date())
+ let filename = "\(timestamp)_workflow_\(latestSummary.workflowName.replacingOccurrences(of: " ", with: "_").lowercased()).\(format.fileExtension)"
+ let data: Data
+
+ switch format {
+ case .text:
+ data = Data(viewModel.exportWorkflowText(summary: latestSummary, changedOnly: changedOnly).utf8)
+ case .csv:
+ data = Data(viewModel.exportWorkflowCSV(summary: latestSummary, changedOnly: changedOnly).utf8)
+ case .json:
+ data = viewModel.exportWorkflowJSONData(summary: latestSummary, changedOnly: changedOnly) ?? Data("[]".utf8)
+ }
+
+ ExportPresenter.share(filename: filename, data: data)
+ }
+}
+
+struct WorkflowComposerView: View {
+ @Environment(\.dismiss) private var dismiss
+ @Bindable var viewModel: DomainViewModel
+ let workflow: DomainWorkflow?
+
+ @State private var name = ""
+ @State private var domainsText = ""
+ @State private var notes = ""
+
+ init(viewModel: DomainViewModel, workflow: DomainWorkflow? = nil) {
+ self.viewModel = viewModel
+ self.workflow = workflow
+ _name = State(initialValue: workflow?.name ?? "")
+ _domainsText = State(initialValue: workflow?.domains.joined(separator: "\n") ?? "")
+ _notes = State(initialValue: workflow?.notes ?? "")
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Workflow") {
+ TextField("Name", text: $name)
+ TextField("Notes", text: $notes, axis: .vertical)
+ .lineLimit(3...6)
+ }
+
+ Section("Domains") {
+ TextField("example.com\napple.com", text: $domainsText, axis: .vertical)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .lineLimit(6...14)
+ }
+ }
+ .navigationTitle(workflow == nil ? "New Workflow" : "Edit Workflow")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ saveWorkflow()
+ }
+ .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || parsedDomains.isEmpty)
+ }
+ }
+ }
+ .preferredColorScheme(.dark)
+ }
+
+ private var parsedDomains: [String] {
+ domainsText
+ .components(separatedBy: CharacterSet(charactersIn: ",\n"))
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ }
+
+ private func saveWorkflow() {
+ if let workflow {
+ viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes)
+ } else {
+ _ = viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes)
+ }
+ dismiss()
+ }
+}
+
+struct WorkflowRunSummaryView: View {
+ @Bindable var viewModel: DomainViewModel
+ let summary: WorkflowRunSummary
+
+ @State private var showAllResults = false
+
+ private var visibleResults: [BatchLookupResult] {
+ showAllResults ? summary.results : summary.results.filter(\.hasMeaningfulChange)
+ }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section("Overview") {
+ statRow(label: "Workflow", value: summary.workflowName)
+ statRow(label: "Processed", value: "\(summary.totalDomains)")
+ statRow(label: "Changed", value: "\(summary.changedDomains)")
+ statRow(label: "Warnings", value: "\(summary.warningDomains)")
+ statRow(label: "Unchanged", value: "\(summary.unchangedDomains)")
+ statRow(label: "Finished", value: summary.generatedAt.formatted(date: .abbreviated, time: .shortened))
+ }
+
+ Section {
+ Toggle("Show unchanged domains", isOn: $showAllResults)
+ }
+
+ Section(visibleResults.isEmpty ? "Meaningful Changes" : "Results") {
+ if visibleResults.isEmpty {
+ Text("No domains with meaningful changes or warnings")
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(visibleResults) { result in
+ if let entry = viewModel.historyEntry(for: result) {
+ NavigationLink {
+ HistoryDetailView(viewModel: viewModel, entry: entry)
+ } label: {
+ BatchResultRowView(result: result)
+ }
+ } else {
+ BatchResultRowView(result: result)
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle("Workflow Summary")
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ Menu {
+ Button("Export TXT") {
+ share(format: .text)
+ }
+ Button("Export CSV") {
+ share(format: .csv)
+ }
+ Button("Export JSON") {
+ share(format: .json)
+ }
+ } label: {
+ Image(systemName: "square.and.arrow.up")
+ }
+
+ if let workflow = viewModel.workflow(withID: summary.workflowID) {
+ Button {
+ viewModel.runWorkflow(workflow)
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .disabled(viewModel.batchLookupRunning)
+ }
+ }
+ }
+ }
+ .preferredColorScheme(.dark)
+ }
+
+ private func statRow(label: String, value: String) -> some View {
+ HStack {
+ Text(label)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text(value)
+ .font(.system(.callout, design: .monospaced))
+ .foregroundStyle(.primary)
+ }
+ }
+
+ private func share(format: DomainExportFormat) {
+ let changedOnly = !showAllResults
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyyMMdd_HHmmss"
+ let timestamp = formatter.string(from: Date())
+ let filename = "\(timestamp)_workflow_summary.\(format.fileExtension)"
+ let data: Data
+
+ switch format {
+ case .text:
+ data = Data(viewModel.exportWorkflowText(summary: summary, changedOnly: changedOnly).utf8)
+ case .csv:
+ data = Data(viewModel.exportWorkflowCSV(summary: summary, changedOnly: changedOnly).utf8)
+ case .json:
+ data = viewModel.exportWorkflowJSONData(summary: summary, changedOnly: changedOnly) ?? Data("[]".utf8)
+ }
+
+ ExportPresenter.share(filename: filename, data: data)
+ }
+}
+
+struct WorkflowBulkAddSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ @Bindable var viewModel: DomainViewModel
+ let title: String
+ let availableDomains: [String]
+
+ @State private var mode: WorkflowDestinationMode = .existing
+ @State private var selectedDomains: Set<String>
+ @State private var selectedWorkflowID: UUID?
+ @State private var newWorkflowName = ""
+ @State private var newWorkflowNotes = ""
+
+ init(viewModel: DomainViewModel, title: String, availableDomains: [String]) {
+ self.viewModel = viewModel
+ self.title = title
+ self.availableDomains = Array(Set(availableDomains)).sorted()
+ _selectedDomains = State(initialValue: Set(availableDomains))
+ _selectedWorkflowID = State(initialValue: viewModel.workflows.first?.id)
+ }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section("Selection") {
+ ForEach(availableDomains, id: \.self) { domain in
+ Button {
+ toggle(domain)
+ } label: {
+ HStack {
+ Text(domain)
+ .foregroundStyle(.primary)
+ Spacer()
+ Image(systemName: selectedDomains.contains(domain) ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(selectedDomains.contains(domain) ? .cyan : .secondary)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+
+ Section("Destination") {
+ Picker("Destination", selection: $mode) {
+ Text("Existing").tag(WorkflowDestinationMode.existing)
+ Text("New").tag(WorkflowDestinationMode.new)
+ }
+ .pickerStyle(.segmented)
+
+ if mode == .existing, !viewModel.workflows.isEmpty {
+ Picker("Workflow", selection: $selectedWorkflowID) {
+ ForEach(viewModel.workflows) { workflow in
+ Text(workflow.name).tag(Optional(workflow.id))
+ }
+ }
+ } else {
+ TextField("Workflow name", text: $newWorkflowName)
+ TextField("Notes", text: $newWorkflowNotes, axis: .vertical)
+ .lineLimit(2...5)
+ }
+ }
+ }
+ .navigationTitle(title)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ save()
+ }
+ .disabled(!canSave)
+ }
+ }
+ .onAppear {
+ if viewModel.workflows.isEmpty {
+ mode = .new
+ }
+ }
+ }
+ .preferredColorScheme(.dark)
+ }
+
+ private var selectedDomainList: [String] {
+ availableDomains.filter { selectedDomains.contains($0) }
+ }
+
+ private var canSave: Bool {
+ guard !selectedDomainList.isEmpty else { return false }
+ switch mode {
+ case .existing:
+ return selectedWorkflowID != nil
+ case .new:
+ return !newWorkflowName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+ }
+
+ private func toggle(_ domain: String) {
+ if selectedDomains.contains(domain) {
+ selectedDomains.remove(domain)
+ } else {
+ selectedDomains.insert(domain)
+ }
+ }
+
+ private func save() {
+ switch mode {
+ case .existing:
+ guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return }
+ viewModel.addDomains(selectedDomainList, to: workflow)
+ case .new:
+ _ = viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes)
+ }
+
+ dismiss()
+ }
+}