Commit 9092ab60f8

9092ab60f82152cc4ae38677c3af4a76013f4e54

parent: e5d6c6c30d

Unsigned

cmc <hello@cleberg.net> · 2026-07-25 05:05 UTC

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.

Layout: unified · split

DomainDig/DomainViewModel+Workflows.swift added +144
@@ -0,0 +1,144 @@
1import Foundation
2import SwiftUI
3
4/// Workflow surface of `DomainViewModel`: looking up workflows, the
5/// collaboration permission checks for a workflow, the CRUD mutators, and
6/// running a workflow's domains as a batch.
7///
8/// `runWorkflow`/`rerunCurrentDomain` drive `startBatchLookup` (the shared batch
9/// primitive used by manual and watchlist runs too), which stays on the main
10/// type along with `persistWorkflows` and the `normalizedDomain(s)` helpers.
11extension DomainViewModel {
12 func workflow(withID id: UUID) -> DomainWorkflow? {
13 workflows.first(where: { $0.id == id })
14 }
15
16 func workflowsContaining(domain: String) -> [DomainWorkflow] {
17 let normalized = normalizedDomain(domain)
18 guard !normalized.isEmpty else { return [] }
19 return workflows.filter { workflow in
20 workflow.domains.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame })
21 }
22 }
23
24 func canEdit(_ workflow: DomainWorkflow) -> Bool {
25 workflow.collaboration?.canEdit ?? true
26 }
27
28 func canDelete(_ workflow: DomainWorkflow) -> Bool {
29 workflow.collaboration?.isOwner ?? true
30 }
31
32 func collaborationLabel(for workflow: DomainWorkflow) -> String? {
33 guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil }
34 return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
35 }
36
37 @discardableResult
38 func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? {
39 let normalizedDomains = normalizedDomains(domains)
40 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
41 guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
42 guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else {
43 upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count)
44 return nil
45 }
46
47 let workflow = DomainWorkflow(
48 name: trimmedName,
49 domains: normalizedDomains,
50 createdAt: Date(),
51 updatedAt: Date(),
52 notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
53 collaboration: CollaborationMetadata(
54 scope: .privateDatabase,
55 ownership: .owner,
56 permission: .editable
57 )
58 )
59 workflows.insert(workflow, at: 0)
60 persistWorkflows()
61 return workflow
62 }
63
64 func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) {
65 guard canEdit(workflow) else { return }
66 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
67 let normalizedDomains = normalizedDomains(domains)
68 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
69 guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return }
70
71 workflows[index].name = trimmedName
72 workflows[index].domains = normalizedDomains
73 workflows[index].notes = notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
74 workflows[index].updatedAt = Date()
75 persistWorkflows()
76 }
77
78 func deleteWorkflow(_ workflow: DomainWorkflow) {
79 guard canDelete(workflow) else { return }
80 CloudSyncService.shared.recordWorkflowDeletion(workflow)
81 workflows.removeAll { $0.id == workflow.id }
82 if latestWorkflowRunSummary?.workflowID == workflow.id {
83 latestWorkflowRunSummary = nil
84 }
85 if activeWorkflowRunID == workflow.id {
86 activeWorkflowRunID = nil
87 activeWorkflowRunName = nil
88 }
89 persistWorkflows()
90 }
91
92 func addDomains(_ domains: [String], to workflow: DomainWorkflow) {
93 guard canEdit(workflow) else { return }
94 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
95 let mergedDomains = normalizedDomains(workflows[index].domains + domains)
96 guard mergedDomains != workflows[index].domains else { return }
97 workflows[index].domains = mergedDomains
98 workflows[index].updatedAt = Date()
99 persistWorkflows()
100 }
101
102 func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) {
103 guard canEdit(workflow) else { return }
104 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
105 workflows[index].domains.remove(atOffsets: offsets)
106 workflows[index].updatedAt = Date()
107 persistWorkflows()
108 }
109
110 func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) {
111 guard canEdit(workflow) else { return }
112 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
113 workflows[index].domains.move(fromOffsets: offsets, toOffset: destination)
114 workflows[index].updatedAt = Date()
115 persistWorkflows()
116 }
117
118 func runWorkflow(_ workflow: DomainWorkflow) {
119 guard !workflow.domains.isEmpty else { return }
120 guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else {
121 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count)
122 return
123 }
124 startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
125 }
126
127 func rerunCurrentDomain(in workflow: DomainWorkflow) {
128 guard workflow.domains.contains(where: { $0.caseInsensitiveCompare(searchedDomain) == .orderedSame }) else {
129 return
130 }
131 runWorkflow(workflow)
132 }
133
134 func refreshWorkflowList() async {
135 workflows = Self.loadWorkflows()
136 await Task.yield()
137 }
138}
139
140private extension String {
141 var nilIfEmpty: String? {
142 isEmpty ? nil : self
143 }
144}
DomainDig/DomainViewModel.swift +7 −134
@@ -275,8 +275,8 @@ final class DomainViewModel {
275 private var lookupStartedAt: Date? 275 private var lookupStartedAt: Date?
276 private var activeBatchDomains: [String] = [] 276 private var activeBatchDomains: [String] = []
277 private var lastBatchStartedAt: Date? 277 private var lastBatchStartedAt: Date?
278 private var activeWorkflowRunID: UUID? 278 var activeWorkflowRunID: UUID?
279 private var activeWorkflowRunName: String? 279 var activeWorkflowRunName: String?
280 private var historyPersistenceSuspended = false 280 private var historyPersistenceSuspended = false
281 private var trackedDomainsPersistenceSuspended = false 281 private var trackedDomainsPersistenceSuspended = false
282 private var historyPersistenceDirty = false 282 private var historyPersistenceDirty = false
@@ -1195,18 +1195,6 @@ final class DomainViewModel {
1195 } 1195 }
1196 } 1196 }
1197 1197
1198 func workflow(withID id: UUID) -> DomainWorkflow? {
1199 workflows.first(where: { $0.id == id })
1200 }
1201
1202 func workflowsContaining(domain: String) -> [DomainWorkflow] {
1203 let normalized = normalizedDomain(domain)
1204 guard !normalized.isEmpty else { return [] }
1205 return workflows.filter { workflow in
1206 workflow.domains.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame })
1207 }
1208 }
1209
1210 func canEdit(_ trackedDomain: TrackedDomain) -> Bool { 1198 func canEdit(_ trackedDomain: TrackedDomain) -> Bool {
1211 trackedDomain.collaboration?.canEdit ?? true 1199 trackedDomain.collaboration?.canEdit ?? true
1212 } 1200 }
@@ -1220,121 +1208,6 @@ final class DomainViewModel {
1220 return "\(collaboration.ownership.title) • \(collaboration.permission.title)" 1208 return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
1221 } 1209 }
1222 1210
1223 func canEdit(_ workflow: DomainWorkflow) -> Bool {
1224 workflow.collaboration?.canEdit ?? true
1225 }
1226
1227 func canDelete(_ workflow: DomainWorkflow) -> Bool {
1228 workflow.collaboration?.isOwner ?? true
1229 }
1230
1231 func collaborationLabel(for workflow: DomainWorkflow) -> String? {
1232 guard let collaboration = workflow.collaboration, collaboration.isShared else { return nil }
1233 return "\(collaboration.ownership.title) • \(collaboration.permission.title)"
1234 }
1235
1236 @discardableResult
1237 func createWorkflow(name: String, domains: [String], notes: String? = nil) -> DomainWorkflow? {
1238 let normalizedDomains = normalizedDomains(domains)
1239 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
1240 guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
1241 guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else {
1242 upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count)
1243 return nil
1244 }
1245
1246 let workflow = DomainWorkflow(
1247 name: trimmedName,
1248 domains: normalizedDomains,
1249 createdAt: Date(),
1250 updatedAt: Date(),
1251 notes: notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
1252 collaboration: CollaborationMetadata(
1253 scope: .privateDatabase,
1254 ownership: .owner,
1255 permission: .editable
1256 )
1257 )
1258 workflows.insert(workflow, at: 0)
1259 persistWorkflows()
1260 return workflow
1261 }
1262
1263 func updateWorkflow(_ workflow: DomainWorkflow, name: String, domains: [String], notes: String?) {
1264 guard canEdit(workflow) else { return }
1265 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
1266 let normalizedDomains = normalizedDomains(domains)
1267 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
1268 guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return }
1269
1270 workflows[index].name = trimmedName
1271 workflows[index].domains = normalizedDomains
1272 workflows[index].notes = notes?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
1273 workflows[index].updatedAt = Date()
1274 persistWorkflows()
1275 }
1276
1277 func deleteWorkflow(_ workflow: DomainWorkflow) {
1278 guard canDelete(workflow) else { return }
1279 CloudSyncService.shared.recordWorkflowDeletion(workflow)
1280 workflows.removeAll { $0.id == workflow.id }
1281 if latestWorkflowRunSummary?.workflowID == workflow.id {
1282 latestWorkflowRunSummary = nil
1283 }
1284 if activeWorkflowRunID == workflow.id {
1285 activeWorkflowRunID = nil
1286 activeWorkflowRunName = nil
1287 }
1288 persistWorkflows()
1289 }
1290
1291 func addDomains(_ domains: [String], to workflow: DomainWorkflow) {
1292 guard canEdit(workflow) else { return }
1293 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
1294 let mergedDomains = normalizedDomains(workflows[index].domains + domains)
1295 guard mergedDomains != workflows[index].domains else { return }
1296 workflows[index].domains = mergedDomains
1297 workflows[index].updatedAt = Date()
1298 persistWorkflows()
1299 }
1300
1301 func removeWorkflowDomains(at offsets: IndexSet, from workflow: DomainWorkflow) {
1302 guard canEdit(workflow) else { return }
1303 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
1304 workflows[index].domains.remove(atOffsets: offsets)
1305 workflows[index].updatedAt = Date()
1306 persistWorkflows()
1307 }
1308
1309 func moveWorkflowDomains(from offsets: IndexSet, to destination: Int, in workflow: DomainWorkflow) {
1310 guard canEdit(workflow) else { return }
1311 guard let index = workflows.firstIndex(where: { $0.id == workflow.id }) else { return }
1312 workflows[index].domains.move(fromOffsets: offsets, toOffset: destination)
1313 workflows[index].updatedAt = Date()
1314 persistWorkflows()
1315 }
1316
1317 func runWorkflow(_ workflow: DomainWorkflow) {
1318 guard !workflow.domains.isEmpty else { return }
1319 guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else {
1320 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count)
1321 return
1322 }
1323 startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
1324 }
1325
1326 func rerunCurrentDomain(in workflow: DomainWorkflow) {
1327 guard workflow.domains.contains(where: { $0.caseInsensitiveCompare(searchedDomain) == .orderedSame }) else {
1328 return
1329 }
1330 runWorkflow(workflow)
1331 }
1332
1333 func refreshWorkflowList() async {
1334 workflows = Self.loadWorkflows()
1335 await Task.yield()
1336 }
1337
1338 func runCustomPortScan(ports: [UInt16]) async { 1211 func runCustomPortScan(ports: [UInt16]) async {
1339 guard !searchedDomain.isEmpty else { 1212 guard !searchedDomain.isEmpty else {
1340 customPortScanError = "Run a domain lookup first" 1213 customPortScanError = "Run a domain lookup first"
@@ -2361,7 +2234,7 @@ final class DomainViewModel {
2361 trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } 2234 trackedDomains.first { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
2362 } 2235 }
2363 2236
2364 private func normalizedDomain(_ domain: String) -> String { 2237 func normalizedDomain(_ domain: String) -> String {
2365 domain 2238 domain
2366 .trimmingCharacters(in: .whitespacesAndNewlines) 2239 .trimmingCharacters(in: .whitespacesAndNewlines)
2367 .replacingOccurrences(of: "https://", with: "") 2240 .replacingOccurrences(of: "https://", with: "")
@@ -2438,7 +2311,7 @@ final class DomainViewModel {
2438 return lookupID 2311 return lookupID
2439 } 2312 }
2440 2313
2441 private func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) { 2314 func startBatchLookup(domains: [String], source: BatchLookupSource, workflow: DomainWorkflow? = nil) {
2442 guard !domains.isEmpty else { return } 2315 guard !domains.isEmpty else { return }
2443 guard !batchLookupRunning else { return } 2316 guard !batchLookupRunning else { return }
2444 2317
@@ -3516,13 +3389,13 @@ final class DomainViewModel {
3516 return DomainDataPortabilityService.loadTrackedDomains() 3389 return DomainDataPortabilityService.loadTrackedDomains()
3517 } 3390 }
3518 3391
3519 private func persistWorkflows() { 3392 func persistWorkflows() {
3520 DomainDataPortabilityService.saveWorkflows(workflows) 3393 DomainDataPortabilityService.saveWorkflows(workflows)
3521 CloudSyncService.shared.scheduleSyncIfNeeded() 3394 CloudSyncService.shared.scheduleSyncIfNeeded()
3522 refreshDataLifecycleSummary() 3395 refreshDataLifecycleSummary()
3523 } 3396 }
3524 3397
3525 private static func loadWorkflows() -> [DomainWorkflow] { 3398 static func loadWorkflows() -> [DomainWorkflow] {
3526 DataMigrationService.migrateIfNeeded() 3399 DataMigrationService.migrateIfNeeded()
3527 return DomainDataPortabilityService.loadWorkflows() 3400 return DomainDataPortabilityService.loadWorkflows()
3528 } 3401 }
@@ -3535,7 +3408,7 @@ final class DomainViewModel {
3535 } 3408 }
3536 } 3409 }
3537 3410
3538 private func normalizedDomains(_ domains: [String]) -> [String] { 3411 func normalizedDomains(_ domains: [String]) -> [String] {
3539 var seen = Set<String>() 3412 var seen = Set<String>()
3540 return domains 3413 return domains
3541 .map(normalizedDomain) 3414 .map(normalizedDomain)