From 9092ab60f82152cc4ae38677c3af4a76013f4e54 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sat, 25 Jul 2026 00:05:53 -0500 Subject: refactor: extract workflow surface into DomainViewModel+Workflows (v5 step 4, 4/n) Fourth slice of the DomainViewModel decomposition (off main; the audit/ monitoring/export stack has merged). - Moves the workflow surface into DomainViewModel+Workflows.swift: workflow lookup (workflow(withID:)/workflowsContaining), the DomainWorkflow collaboration checks, the CRUD mutators (create/update/delete/add/remove/move), runWorkflow/rerunCurrentDomain, and refreshWorkflowList. Pure move. The TrackedDomain overloads of canEdit/canDelete/collaborationLabel stay on the main type (they're watchlist collaboration, not workflow). - Promotes the shared helpers the moved methods reach to internal, all staying on the main type: persistWorkflows (also called by clearWorkflows), startBatchLookup (the batch primitive shared with manual/watchlist runs), normalizedDomain/normalizedDomains, loadWorkflows, and the activeWorkflowRunID/ Name state. The extension carries its own fileprivate String.nilIfEmpty, matching the per-file pattern already used across the codebase. DomainViewModel.swift: 4433 -> 4306 lines (4864 at the start of step 4). App builds clean; unit suite 58/58. No project.pbxproj change. --- DomainDig/DomainViewModel+Workflows.swift | 144 ++++++++++++++++++++++++++++++ DomainDig/DomainViewModel.swift | 141 ++--------------------------- 2 files changed, 151 insertions(+), 134 deletions(-) create mode 100644 DomainDig/DomainViewModel+Workflows.swift diff --git a/DomainDig/DomainViewModel+Workflows.swift b/DomainDig/DomainViewModel+Workflows.swift new file mode 100644 index 0000000..d1e69b4 --- /dev/null +++ b/DomainDig/DomainViewModel+Workflows.swift @@ -0,0 +1,144 @@ +import Foundation +import SwiftUI + +/// Workflow surface of `DomainViewModel`: looking up workflows, the +/// collaboration permission checks for a workflow, the CRUD mutators, and +/// running a workflow's domains as a batch. +/// +/// `runWorkflow`/`rerunCurrentDomain` drive `startBatchLookup` (the shared batch +/// primitive used by manual and watchlist runs too), which stays on the main +/// type along with `persistWorkflows` and the `normalizedDomain(s)` helpers. +extension 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 }) + } + } + + func canEdit(_ workflow: DomainWorkflow) -> Bool { + workflow.collaboration?.canEdit ?? true + } + + func canDelete(_ workflow: DomainWorkflow) -> Bool { + workflow.collaboration?.isOwner ?? true + } + + func collaborationLabel(for workflow: DomainWorkflow) -> String? { + guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil } + return "\(collaboration.ownership.title) • \(collaboration.permission.title)" + } + + @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 } + guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { + upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count) + return nil + } + + let workflow = DomainWorkflow( + name: trimmedName, + domains: normalizedDomains, + createdAt: Date(), + updatedAt: Date(), + notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + collaboration: CollaborationMetadata( + scope: .privateDatabase, + ownership: .owner, + permission: .editable + ) + ) + workflows.insert(workflow, at: 0) + persistWorkflows() + return workflow + } + + func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) { + guard canEdit(workflow) else { return } + 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) { + guard canDelete(workflow) else { return } + CloudSyncService.shared.recordWorkflowDeletion(workflow) + 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 canEdit(workflow) else { return } + 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 canEdit(workflow) else { return } + 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 canEdit(workflow) else { return } + 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 } + guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { + upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count) + 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() + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 250d46d..e0f649b 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -275,8 +275,8 @@ final class DomainViewModel { private var lookupStartedAt: Date? private var activeBatchDomains: [String] = [] private var lastBatchStartedAt: Date? - private var activeWorkflowRunID: UUID? - private var activeWorkflowRunName: String? + var activeWorkflowRunID: UUID? + var activeWorkflowRunName: String? private var historyPersistenceSuspended = false private var trackedDomainsPersistenceSuspended = false private var historyPersistenceDirty = false @@ -1195,18 +1195,6 @@ 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 }) - } - } - func canEdit(_ trackedDomain: TrackedDomain) -> Bool { trackedDomain.collaboration?.canEdit ?? true } @@ -1220,121 +1208,6 @@ final class DomainViewModel { return "\(collaboration.ownership.title) • \(collaboration.permission.title)" } - func canEdit(_ workflow: DomainWorkflow) -> Bool { - workflow.collaboration?.canEdit ?? true - } - - func canDelete(_ workflow: DomainWorkflow) -> Bool { - workflow.collaboration?.isOwner ?? true - } - - func collaborationLabel(for workflow: DomainWorkflow) -> String? { - guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil } - return "\(collaboration.ownership.title) • \(collaboration.permission.title)" - } - - @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 } - guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { - upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count) - return nil - } - - let workflow = DomainWorkflow( - name: trimmedName, - domains: normalizedDomains, - createdAt: Date(), - updatedAt: Date(), - notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, - collaboration: CollaborationMetadata( - scope: .privateDatabase, - ownership: .owner, - permission: .editable - ) - ) - workflows.insert(workflow, at: 0) - persistWorkflows() - return workflow - } - - func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) { - guard canEdit(workflow) else { return } - 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) { - guard canDelete(workflow) else { return } - CloudSyncService.shared.recordWorkflowDeletion(workflow) - 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 canEdit(workflow) else { return } - 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 canEdit(workflow) else { return } - 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 canEdit(workflow) else { return } - 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 } - guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { - upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count) - 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" @@ -2361,7 +2234,7 @@ final class DomainViewModel { trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } } - private func normalizedDomain(_ domain: String) -> String { + func normalizedDomain(_ domain: String) -> String { domain .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: "https://", with: "") @@ -2438,7 +2311,7 @@ final class DomainViewModel { return lookupID } - private func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) { + func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) { guard !domains.isEmpty else { return } guard !batchLookupRunning else { return } @@ -3516,13 +3389,13 @@ final class DomainViewModel { return DomainDataPortabilityService.loadTrackedDomains() } - private func persistWorkflows() { + func persistWorkflows() { DomainDataPortabilityService.saveWorkflows(workflows) CloudSyncService.shared.scheduleSyncIfNeeded() refreshDataLifecycleSummary() } - private static func loadWorkflows() -> [DomainWorkflow] { + static func loadWorkflows() -> [DomainWorkflow] { DataMigrationService.migrateIfNeeded() return DomainDataPortabilityService.loadWorkflows() } @@ -3535,7 +3408,7 @@ final class DomainViewModel { } } - private func normalizedDomains(_ domains: [String]) -> [String] { + func normalizedDomains(_ domains: [String]) -> [String] { var seen = Set() return domains .map(normalizedDomain) -- cgit v1.2.3