From 9384a668bf36f23f71e25d05b490d1040d308845 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 22 Apr 2026 10:14:30 -0500 Subject: 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 --- DomainDig.xcodeproj/project.pbxproj | 8 +- DomainDig/AppVersion.swift | 2 +- DomainDig/ContentView.swift | 112 +++++++ DomainDig/DomainViewModel.swift | 207 +++++++++++- DomainDig/HistoryView.swift | 20 +- DomainDig/Models.swift | 45 +++ DomainDig/WatchlistView.swift | 12 + DomainDig/WorkflowsView.swift | 634 ++++++++++++++++++++++++++++++++++++ 8 files changed, 1029 insertions(+), 11 deletions(-) create mode 100644 DomainDig/WorkflowsView.swift 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 = [.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) @@ -224,6 +240,12 @@ struct ContentView: View { Label("Saved Domains", systemImage: "bookmark") } + NavigationLink { + WorkflowsView(viewModel: viewModel) + } label: { + Label("Workflows", systemImage: "square.stack.3d.down.right") + } + NavigationLink { SettingsView(viewModel: viewModel) } label: { @@ -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? @@ -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() return domains.filter { domain in @@ -2032,6 +2221,14 @@ final class DomainViewModel { } } + private func normalizedDomains(_ domains: [String]) -> [String] { + var seen = Set() + 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 { + 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 { + 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 + @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() + } +} -- cgit v1.2.3